[Add] UI optimizations, Graphs, More console commands

This commit is contained in:
Alex Emmet 2026-02-20 20:24:11 +01:00
commit 997c567c80
13 changed files with 424 additions and 115 deletions

View file

@ -1,5 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use json::JsonValue;
use ratatui::{
Frame,
layout::Rect,
@ -7,14 +6,15 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use uuid::Uuid;
use crate::{
ACTIVE_TASKS,
data::communication::{CommunicationType, CommunicationValue, DataTypes},
ACTIVE_TASKS, RELOAD, SHUTDOWN,
data::communication::{CommunicationType, CommunicationValue},
gui::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
ui::{FPS, UI},
ui::FPS,
util::borders::draw_block_joins,
},
log, log_cv,
@ -22,11 +22,7 @@ use crate::{
users::{user_manager, user_profile::UserProfile},
util::file_util,
};
use std::{
any::Any,
sync::Arc,
time::{Duration, SystemTime},
};
use std::{any::Any, time::Duration};
pub struct ConsoleCard {
focused: bool,
@ -129,8 +125,12 @@ impl InteractableElement for ConsoleCard {
match key.code {
KeyCode::Enter => {
let command = self.content.clone();
let id = Uuid::new_v4();
let task_id = format!("command_{}_{}", command, id);
ACTIVE_TASKS.insert(task_id.clone());
tokio::spawn(async move {
run_command(&command).await;
ACTIVE_TASKS.remove(&task_id);
});
self.content = "".to_string();
InteractionResult::Handled
@ -171,66 +171,43 @@ pub async fn run_command(command: &str) {
["tasks"] => {
let active_tasks: Vec<String> =
ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect();
log!("Active tasks: {:?}", active_tasks);
let info = if *SHUTDOWN.read().await && *RELOAD.read().await {
"Rebooting, "
} else if *SHUTDOWN.read().await {
"Shutting , "
} else {
""
};
log!("{}Active tasks: {:?}", info, active_tasks);
}
["fps"] => {
log!("FPS: {}", *FPS.read().await);
let (fps, skips) = *FPS.read().await;
log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips);
}
["help"] => {
log!("Available commands: tasks, fps, ping, user");
}
["help", "tasks"] => {
log!("Tasks command usage: tasks");
}
["help", "fps"] => {
log!("FPS command usage: fps");
}
["help", "ping"] => {
log!("Ping command usage: ping [time]");
}
["help", "user"] => {
log!("User command usage: user add <username> | user remove <username> | user list");
}
["ping"] => {
let time = 20;
let now = SystemTime::now();
let conn = {
let guard = OMIKRON_CONNECTION.read().await;
guard.as_ref().cloned()
};
let conn = match conn {
Some(c) => c,
None => return,
};
let response_cv = conn
.await_response(
&CommunicationValue::new(CommunicationType::ping),
Some(Duration::from_secs(time)),
)
.await;
let elapsed = now.elapsed().unwrap_or(Duration::ZERO);
match response_cv {
Ok(response) => log_cv!(response.add_data(
DataTypes::get_time,
JsonValue::from(elapsed.as_millis() as i64)
)),
Err(err) => log!("Ping error: {:?}", err),
}
ping(20).await;
}
["ping", time] => {
let time = time.parse::<u64>().unwrap_or(20);
let conn = {
let guard = OMIKRON_CONNECTION.read().await;
guard.as_ref().cloned()
};
let conn = match conn {
Some(c) => c,
None => return,
};
let response_cv = conn
.await_response(
&CommunicationValue::new(CommunicationType::ping),
Some(Duration::from_secs(time)),
)
.await;
match response_cv {
Ok(response) => log_cv!(response),
Err(err) => log!("Ping error: {:?}", err),
}
ping(time).await;
}
["user", "add", username] => {
if let (Some(user), Some(_)) = user_manager::create_user(username).await {
@ -273,3 +250,28 @@ pub async fn run_command(command: &str) {
}
}
}
pub async fn ping(time: u64) {
let time = time;
let conn = {
let guard = OMIKRON_CONNECTION.read().await;
guard.as_ref().cloned()
};
let conn = match conn {
Some(c) => c,
None => return,
};
let response_cv = conn
.await_response(
&CommunicationValue::new(CommunicationType::ping),
Some(Duration::from_secs(time)),
)
.await;
match response_cv {
Ok(response) => log_cv!(response),
Err(err) => log!("Ping error: {:?}", err),
}
}

View file

@ -0,0 +1,208 @@
use std::{any::Any, sync::Arc};
use crossterm::event::KeyEvent;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{
Block, Borders,
canvas::{Canvas, Line},
},
};
use crate::{
APP_STATE,
gui::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
ui::UI,
util::borders::draw_block_joins,
},
};
pub enum GRAPHS {
Ram,
Cpu,
Ping,
}
impl GRAPHS {
pub fn get_graph(&self) -> Vec<(f64, f64)> {
match self {
GRAPHS::Ram => APP_STATE.lock().unwrap().ram.clone(),
GRAPHS::Cpu => APP_STATE.lock().unwrap().cpu.clone(),
GRAPHS::Ping => APP_STATE.lock().unwrap().ping.clone(),
}
}
pub fn get_unit(&self) -> String {
match self {
GRAPHS::Ram => "MB".to_string(),
GRAPHS::Cpu => "%".to_string(),
GRAPHS::Ping => "ms".to_string(),
}
}
}
pub struct GraphCard {
ui: Arc<UI>,
graph_type: GRAPHS,
focused: bool,
pub title: String,
color: Color,
borders: Borders,
joins: Borders,
open: bool,
}
impl GraphCard {
pub fn new(ui: Arc<UI>, graph_type: GRAPHS, title: String) -> Self {
Self {
ui,
graph_type,
focused: false,
title,
color: Color::White,
borders: Borders::ALL,
joins: Borders::NONE,
open: false,
}
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
}
impl Element for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, r: Rect) {
if self.open {
let graph = self.graph_type.get_graph();
let unit = self.graph_type.get_unit();
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
let min_y = graph
.iter()
.map(|(_, y)| *y)
.filter(|y| *y > 0.0)
.min_by(|a, b| a.total_cmp(b))
.unwrap_or(0.0);
let max_y = graph.iter().map(|(_, y)| *y).fold(f64::MIN, f64::max);
let block = Block::default()
.title(format!(
"─{}:─{}{}──{}/{}─MIN/MAX",
self.title,
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
unit,
min_y as i64,
max_y as i64,
))
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
});
let canvas = Canvas::default()
.block(block)
.x_bounds([min_x, max_x])
.y_bounds([0.0, 100.0])
.paint(|ctx| {
for (x, y) in &graph {
ctx.draw(&Line {
x1: *x,
y1: 0.0,
x2: *x,
y2: *y,
color: self.color,
});
}
});
f.render_widget(canvas, r);
} else {
let block = Block::default()
.title("")
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
});
f.render_widget(block, r);
}
draw_block_joins(f, r, self.borders, self.joins);
}
}
impl JoinableElement for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &dyn Element {
self
}
fn as_element_mut(&mut self) -> &mut dyn Element {
self
}
fn set_borders(&mut self, borders: Borders) {
self.borders = borders;
}
fn set_joins(&mut self, joins: Borders) {
self.joins = joins;
}
}
impl InteractableElement for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &dyn Element {
self
}
fn as_element_mut(&mut self) -> &mut dyn Element {
self
}
fn interact(&mut self, _key: KeyEvent) -> InteractionResult {
InteractionResult::Handled
}
fn can_focus(&self) -> bool {
true
}
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self, f: bool) {
self.focused = f;
}
}

25
src/gui/elements/log_card.rs Normal file → Executable file
View file

@ -30,7 +30,7 @@ impl LogCard {
pub fn new() -> Self {
Self {
focused: false,
scroll: 0,
scroll: 1,
borders: Borders::ALL,
joins: Borders::NONE,
}
@ -51,7 +51,9 @@ impl Element for LogCard {
let lines: Vec<Line> = logs
.iter()
.map(|log| Line::from(Span::raw(log.line.clone())).style(log.color))
.map(|log| {
Line::from(Span::raw(log.line.clone())).style(Style::default().fg(log.color))
})
.collect();
let block = Block::default()
@ -67,10 +69,17 @@ impl Element for LogCard {
Style::default()
});
let inner_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let base_scroll = total_lines.saturating_sub(inner_height) as u16;
let scroll = base_scroll.saturating_sub(self.scroll);
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));
.scroll((scroll, 0));
f.render_widget(paragraph, area);
draw_block_joins(f, area, self.borders, self.joins);
@ -121,13 +130,17 @@ impl InteractableElement for LogCard {
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
match key.code {
KeyCode::Char('J') | KeyCode::Char('j') => {
if self.scroll > 0 {
self.scroll -= 1;
let state = APP_STATE.lock().unwrap();
let logs = state.get_logs();
if self.scroll < logs.len() as u16 {
self.scroll += 1;
}
InteractionResult::Handled
}
KeyCode::Char('K') | KeyCode::Char('k') => {
self.scroll += 1;
if self.scroll > 1 {
self.scroll -= 1;
}
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,

View file

@ -3,6 +3,7 @@ use crate::gui::ui::{UI, UNIQUE};
use crate::{RELOAD, SHUTDOWN};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
pub fn setup_input_handler(ui: Arc<UI>) {
@ -30,7 +31,7 @@ pub fn setup_input_handler(ui: Arc<UI>) {
if key_event.kind == KeyEventKind::Press {
let uic = ui.clone();
handle_input(key_event, uic).await;
*UNIQUE.write().await = true;
UNIQUE.store(true, Ordering::Relaxed);
}
}
}

View file

@ -1,6 +1,7 @@
pub mod elements {
pub mod console_card;
pub mod elements;
pub mod graph_card;
pub mod log_card;
}
pub mod screens {

View file

@ -2,6 +2,7 @@ use crate::gui::{
elements::{
console_card::ConsoleCard,
elements::{InteractableElement, JoinableElement},
graph_card::{GRAPHS, GraphCard},
log_card::LogCard,
},
interaction_result::InteractionResult,
@ -19,34 +20,49 @@ use ratatui::{
use std::{any::Any, sync::Arc};
pub struct MainScreen {
ui: Arc<UI>,
elements: Vec<Box<dyn InteractableElement>>,
nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize),
graphs_open: bool,
}
impl MainScreen {
pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
let mut nav_grid: Vec<Vec<Option<usize>>> = Vec::new();
let nav_grid = vec![
vec![Some(0), Some(2)],
vec![Some(1), Some(3)],
vec![None, Some(4)],
];
let mut log_card = LogCard::new();
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let mut console_card = ConsoleCard::new("Console", "");
console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card));
elements.push(Box::new(console_card));
nav_grid.push(vec![Some(0)]);
nav_grid.push(vec![Some(1)]);
let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM Usage".into());
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
elements.push(Box::new(ram_graph));
let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU Usage".into());
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
cpu_graph.set_joins(Borders::TOP);
elements.push(Box::new(cpu_graph));
let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into());
ping_graph.set_joins(Borders::TOP);
elements.push(Box::new(ping_graph));
let graphs_open = true;
let mut screen = MainScreen {
ui,
elements,
nav_grid,
selected_coords: (1, 0),
graphs_open,
};
screen.focus_current();
screen
}
@ -101,7 +117,6 @@ impl MainScreen {
_ => {}
}
// Clamp X to row length
if let Some(row) = self.nav_grid.get(y) {
if x >= row.len() {
x = row.len() - 1;
@ -129,10 +144,6 @@ impl Screen for MainScreen {
self
}
fn get_ui(&self) -> &Arc<UI> {
&self.ui
}
fn render(&self, f: &mut Frame, rect: Rect) {
let main_block = Block::default().title("Main").borders(Borders::ALL);
f.render_widget(main_block, rect);
@ -142,14 +153,42 @@ impl Screen for MainScreen {
horizontal: 1,
});
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
let graphs_width = if self.graphs_open { 30 } else { 2 };
let main_width = inner.width.saturating_sub(graphs_width);
let chunks = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
Constraint::Length(main_width),
Constraint::Length(graphs_width),
])
.split(inner);
let left_chunks =
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(chunks[0]);
if let Some(Some(index)) = self.nav_grid.get(0).and_then(|r| r.get(0)) {
self.elements[*index].as_element().render(f, chunks[0]);
self.elements[*index].as_element().render(f, left_chunks[0]);
}
if let Some(Some(index)) = self.nav_grid.get(1).and_then(|r| r.get(0)) {
self.elements[*index].as_element().render(f, left_chunks[1]);
}
if let Some(Some(index)) = self.nav_grid.get(1).and_then(|r| r.get(0)) {
self.elements[*index].as_element().render(f, chunks[1]);
let graph_elements: Vec<_> = self
.elements
.iter()
.filter(|el| el.as_any().is::<GraphCard>())
.collect();
let graph_chunks = Layout::vertical(
graph_elements
.iter()
.map(|_| Constraint::Length(chunks[1].height / graph_elements.len() as u16))
.collect::<Vec<_>>(),
)
.split(chunks[1]);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area);
}
}
@ -159,6 +198,15 @@ impl Screen for MainScreen {
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
return InteractionResult::Handled;
}
_ => {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {

View file

@ -1,9 +1,9 @@
use std::{any::Any, sync::Arc};
use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect};
use crate::gui::{interaction_result::InteractionResult, ui::UI};
use crate::gui::interaction_result::InteractionResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
@ -20,8 +20,6 @@ pub trait Screen: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn get_ui(&self) -> &Arc<UI>;
fn render(&self, f: &mut Frame, rect: Rect);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
}

View file

@ -51,10 +51,6 @@ impl Screen for TermsCheckerScreen {
self
}
fn get_ui(&self) -> &Arc<UI> {
&self.ui
}
fn render(&self, f: &mut Frame, size: Rect) {
let mut needed_height = 5;

View file

@ -121,10 +121,6 @@ impl Screen for TermsUpdaterScreen {
self
}
fn get_ui(&self) -> &Arc<UI> {
&self.ui
}
fn render(&self, f: &mut Frame, size: Rect) {
let mut needed_height = 5;

0
src/gui/tui.rs Normal file
View file

View file

@ -9,16 +9,21 @@ use crossterm::event::KeyEvent;
use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend, init};
use std::{
collections::VecDeque,
io::Stdout,
sync::{Arc, Mutex},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{sync::RwLock, time::Instant};
/// UI state and rendering
pub static UNIQUE: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
pub static UNIQUE: AtomicBool = AtomicBool::new(true);
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
pub static FPS: Lazy<RwLock<f64>> = Lazy::new(|| RwLock::new(0.0));
pub struct UI {
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
screen: Arc<RwLock<Option<Box<dyn Screen>>>>,
@ -27,26 +32,71 @@ pub struct UI {
pub fn start_tui() -> Arc<UI> {
let ui = Arc::new(UI::new());
let uic = ui.clone();
ACTIVE_TASKS.insert("UI Renderer".to_string());
tokio::spawn(async move {
ACTIVE_TASKS.insert("UI Renderer".to_string());
let mut last_render = Instant::now();
let mut last: Vec<f64> = Vec::new();
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
let mut fps_sum = 0.0;
let mut skip_sum: u32 = 0;
let mut skipped = 0;
loop {
if *SHUTDOWN.read().await {
break;
}
if *UNIQUE.read().await {
if skipped > 5 || UNIQUE.load(Ordering::Relaxed) {
uic.render().await;
skip_samples.push_back(skipped);
skip_sum += skipped as u32;
if skip_samples.len() > 20 {
if let Some(old) = skip_samples.pop_front() {
skip_sum -= old as u32;
}
}
skipped = 0;
let elapsed = last_render.elapsed().as_secs_f64();
if elapsed > 0.0 {
last.push(1.0 / elapsed);
let fps = 1.0 / elapsed;
fps_samples.push_back(fps);
fps_sum += fps;
if fps_samples.len() > 20 {
if let Some(old) = fps_samples.pop_front() {
fps_sum -= old;
}
}
}
if last.len() > 10 {
last.remove(0);
}
*FPS.write().await = last.iter().sum::<f64>() / last.len() as f64;
let avg_fps = if !fps_samples.is_empty() {
fps_sum / fps_samples.len() as f64
} else {
0.0
};
let avg_skips_percentage = if !skip_samples.is_empty() {
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
let total_iterations = avg_skipped + 1.0;
(avg_skipped / total_iterations) * 100.0
} else {
0.0
};
*FPS.write().await = (avg_fps, avg_skips_percentage);
last_render = Instant::now();
UNIQUE.store(false, Ordering::Relaxed);
} else {
skipped += 1;
}
tokio::time::sleep(Duration::from_millis(16)).await;
}

View file

@ -40,7 +40,7 @@ pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false));
pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
#[tokio::main(flavor = "multi_thread", worker_threads = 8)]
#[tokio::main(flavor = "multi_thread", worker_threads = 16)]
#[allow(unused_must_use, dead_code)]
async fn main() {
while *RELOAD.read().await {
@ -57,7 +57,7 @@ async fn main() {
if ACTIVE_TASKS.is_empty() {
break;
}
sleep(Duration::from_secs(1)).await;
sleep(Duration::from_millis(100)).await;
}
println!("You need to accept our End User Licence Agreement before launching!");
println!("You can find this at 'agreements'!");
@ -176,7 +176,7 @@ async fn main() {
if !omikron.is_connected().await {
break;
}
sleep(Duration::from_secs(1)).await;
sleep(Duration::from_millis(100)).await;
}
}
if *RELOAD.read().await {

View file

@ -2,7 +2,7 @@ use std::{
fs::{self, OpenOptions},
io::Write,
path::Path,
sync::{OnceLock, mpsc},
sync::{OnceLock, atomic::Ordering, mpsc},
thread,
time::{SystemTime, UNIX_EPOCH},
};
@ -122,9 +122,7 @@ pub fn log_internal_translated(
args: Vec<String>,
) {
if let Some(tx) = LOGGER.get() {
tokio::spawn(async move {
*UNIQUE.write().await = true;
});
UNIQUE.store(true, Ordering::Relaxed);
let _ = tx.send(LogMessage {
timestamp_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -142,9 +140,7 @@ pub fn log_internal_translated(
pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: String) {
if let Some(tx) = LOGGER.get() {
tokio::spawn(async move {
*UNIQUE.write().await = true;
});
UNIQUE.store(true, Ordering::Relaxed);
let _ = tx.send(LogMessage {
timestamp_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)