xps 13 3980

This commit is contained in:
2026-02-26 13:37:04 +01:00
commit d6ac8e5931
21 changed files with 4562 additions and 0 deletions

353
src/ui/dashboard.rs Normal file
View File

@@ -0,0 +1,353 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Style, Modifier, Color},
text::{Span, Line},
widgets::{Block, Borders, List, ListItem, Paragraph, Chart, Dataset, Axis, BorderType, GraphType},
symbols::Marker,
Frame,
};
use crate::mediator::TelemetryState;
use crate::ui::theme::*;
/// DashboardState maintains UI-specific state that isn't part of the core telemetry,
/// such as the accumulated diagnostic logs.
pub struct DashboardState {
pub logs: Vec<String>,
}
impl DashboardState {
pub fn new() -> Self {
Self {
logs: vec!["FerroTherm Initialized.".to_string()],
}
}
/// Updates the UI state based on new telemetry.
pub fn update(&mut self, _state: &TelemetryState) {}
}
/// Main entry point for drawing the dashboard.
pub fn draw_dashboard(
f: &mut Frame,
area: Rect,
state: &TelemetryState,
ui_state: &DashboardState,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Header
Constraint::Length(3), // Top Block (System Info)
Constraint::Min(15), // Middle (Gauges + Blocks + Graphs)
Constraint::Length(8), // Bottom (Logs)
])
.split(area);
draw_header(f, chunks[0], state);
draw_sys_info(f, chunks[1], state);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(32), // Gauges + Small Blocks
Constraint::Min(20), // Graphs
])
.split(chunks[2]);
let left_side_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), // Gauges
Constraint::Length(3), // Cooling
Constraint::Length(3), // CPU State
Constraint::Min(4), // Metadata
])
.split(middle_chunks[0]);
draw_gauges(f, left_side_chunks[0], state);
draw_cooling(f, left_side_chunks[1], state);
draw_cpu_state(f, left_side_chunks[2], state);
draw_metadata(f, left_side_chunks[3], state);
let right_side_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(middle_chunks[1]);
draw_temp_graph(f, right_side_chunks[0], state);
draw_pwr_graph(f, right_side_chunks[1], state);
draw_freq_graph(f, right_side_chunks[2], state);
draw_logs(f, chunks[3], ui_state);
}
fn draw_header(f: &mut Frame, area: Rect, state: &TelemetryState) {
let uptime_secs = state.tick / 2;
let uptime = format!("{:02}:{:02}:{:02}", uptime_secs / 3600, (uptime_secs % 3600) / 60, uptime_secs % 60);
let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into());
let left = Span::styled(format!(" 󰈐 {} ", hostname), Style::default().fg(C_MAUVE).add_modifier(Modifier::BOLD));
let center = Span::styled(" FERROTHERM THERMAL BENCH ", Style::default().fg(C_LAVENDER).add_modifier(Modifier::BOLD));
let right = Span::styled(format!(" UPTIME: {} ", uptime), Style::default().fg(C_SUBTEXT));
let total_width = area.width;
let left_width = left.width() as u16;
let center_width = center.width() as u16;
let right_width = right.width() as u16;
let space1 = (total_width.saturating_sub(left_width + center_width + right_width)) / 2;
let space2 = total_width.saturating_sub(left_width + center_width + right_width + space1);
let header_text = Line::from(vec![
left,
Span::raw(" ".repeat(space1 as usize)),
center,
Span::raw(" ".repeat(space2 as usize)),
right,
]);
f.render_widget(Paragraph::new(header_text).style(Style::default().bg(C_SURFACE0)), area);
}
fn draw_sys_info(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" SYSTEM INFO ");
let inner = block.inner(area);
f.render_widget(block, area);
let info_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
])
.split(inner);
let cpu_info = Line::from(vec![
Span::styled(" CPU: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.cpu_model, Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(cpu_info), info_chunks[0]);
let ram_info = Line::from(vec![
Span::styled(" RAM: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{} GB", state.total_ram_gb), Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(ram_info), info_chunks[1]);
let gov_info = Line::from(vec![
Span::styled(" GOV: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.governor, Style::default().fg(C_TEAL)),
]);
f.render_widget(Paragraph::new(gov_info), info_chunks[2]);
let pl_info = Line::from(vec![
Span::styled(" PL1/2: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{:.1}/{:.1}W", state.pl1_limit, state.pl2_limit), Style::default().fg(C_PEACH)),
]);
f.render_widget(Paragraph::new(pl_info), info_chunks[3]);
}
fn draw_gauges(f: &mut Frame, area: Rect, state: &TelemetryState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(area);
let temp_color = if state.cpu_temp > 90.0 { C_RED } else if state.cpu_temp > 75.0 { C_YELLOW } else { C_TEAL };
draw_vertical_gauge(f, chunks[0], "TEMP", state.cpu_temp, 100.0, temp_color, "°C");
draw_vertical_gauge(f, chunks[1], "PWR", state.power_w, 50.0, C_PEACH, "W");
}
fn draw_cooling(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" COOLING ");
let inner = block.inner(area);
f.render_widget(block, area);
let info = Line::from(vec![
Span::styled(" Tier: ", Style::default().fg(C_LAVENDER)),
Span::styled(&state.fan_tier, Style::default().fg(C_TEAL)),
Span::styled(" | RPM: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{}", state.fan_rpm), Style::default().fg(C_TEXT)),
]);
f.render_widget(Paragraph::new(info), inner);
}
fn draw_cpu_state(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" CPU STATE ");
let inner = block.inner(area);
f.render_widget(block, area);
let info = Line::from(vec![
Span::styled(" Frequency: ", Style::default().fg(C_LAVENDER)),
Span::styled(format!("{:.0} MHz", state.current_freq), Style::default().fg(C_SAPPHIRE)),
]);
f.render_widget(Paragraph::new(info), inner);
}
fn draw_metadata(f: &mut Frame, area: Rect, state: &TelemetryState) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0))
.title(" HARDWARE INFO ");
let inner = block.inner(area);
f.render_widget(block, area);
if state.metadata.is_empty() {
f.render_widget(Paragraph::new(" No extra telemetry data.").style(Style::default().fg(C_OVERLAY0)), inner);
return;
}
let items: Vec<ListItem> = state.metadata.iter().map(|(k, v)| {
ListItem::new(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(C_LAVENDER)),
Span::styled(v, Style::default().fg(C_TEXT)),
]))
}).collect();
f.render_widget(List::new(items), inner);
}
fn draw_vertical_gauge(f: &mut Frame, area: Rect, label: &str, value: f32, max: f32, color: Color, unit: &str) {
let block = Block::default()
.title(format!(" {} ", label))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_SURFACE0));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height == 0 || inner.width == 0 { return; }
let percent = (value / max).clamp(0.0, 1.0);
let filled_height = (percent * inner.height as f32).round() as u16;
for y in 0..inner.height {
let is_filled = y < filled_height;
let symbol = if is_filled { "" } else { "" };
let style = if is_filled { Style::default().fg(color) } else { Style::default().fg(C_SURFACE0) };
let draw_y = inner.y + inner.height.saturating_sub(1).saturating_sub(y);
let row_text = symbol.repeat(inner.width as usize);
f.render_widget(Paragraph::new(row_text).style(style), Rect::new(inner.x, draw_y, inner.width, 1));
}
let val_str = format!("{:.1}{}", value, unit);
let val_len = val_str.len() as u16;
let val_x = inner.x + inner.width.saturating_sub(val_len) / 2;
let val_y = inner.y + inner.height / 2;
f.render_widget(
Paragraph::new(val_str).style(Style::default().fg(C_TEXT).bg(color).add_modifier(Modifier::BOLD)),
Rect::new(val_x, val_y, inner.width.min(val_len), 1)
);
}
fn draw_temp_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let temp_data: Vec<(f64, f64)> = state.history_temp.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let temp_dataset = Dataset::default()
.name("Temp")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_RED))
.data(&temp_data);
let chart = Chart::new(vec![temp_dataset])
.block(Block::default().title(" CPU TEMPERATURE (°C) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([30.0, 100.0]).labels(vec![
Line::from("30"),
Line::from("65"),
Line::from("100"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_pwr_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let pwr_data: Vec<(f64, f64)> = state.history_watts.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let pwr_dataset = Dataset::default()
.name("Power")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_PEACH))
.data(&pwr_data);
let chart = Chart::new(vec![pwr_dataset])
.block(Block::default().title(" PACKAGE POWER (W) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([0.0, 50.0]).labels(vec![
Line::from("0"),
Line::from("25"),
Line::from("50"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_freq_graph(f: &mut Frame, area: Rect, state: &TelemetryState) {
let freq_data: Vec<(f64, f64)> = state.history_mhz.iter().enumerate()
.map(|(i, &v)| (i as f64, v as f64)).collect();
let freq_dataset = Dataset::default()
.name("Freq")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(C_SAPPHIRE))
.data(&freq_data);
let chart = Chart::new(vec![freq_dataset])
.block(Block::default().title(" CPU FREQUENCY (MHz) ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0)))
.x_axis(Axis::default().bounds([0.0, 120.0]).style(Style::default().fg(C_OVERLAY0)))
.y_axis(Axis::default().bounds([0.0, 5000.0]).labels(vec![
Line::from("0"),
Line::from("2500"),
Line::from("5000"),
]).style(Style::default().fg(C_OVERLAY0)));
f.render_widget(chart, area);
}
fn draw_logs(f: &mut Frame, area: Rect, ui_state: &DashboardState) {
let log_items: Vec<ListItem> = ui_state.logs.iter().rev().take(area.height as usize).map(|l| {
let style = if l.contains("") || l.contains("failed") || l.contains("Abort") {
Style::default().fg(C_RED)
} else if l.contains("") || l.contains("complete") {
Style::default().fg(C_GREEN)
} else {
Style::default().fg(C_TEAL)
};
ListItem::new(format!(" {} ", l)).style(style)
}).collect();
f.render_widget(
List::new(log_items)
.block(Block::default().title(" DIAGNOSTIC LOG ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(C_SURFACE0))),
area
);
}

2
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod theme;
pub mod dashboard;

14
src/ui/theme.rs Normal file
View File

@@ -0,0 +1,14 @@
use ratatui::style::Color;
pub const C_SURFACE0: Color = Color::Rgb(54, 58, 79);
pub const C_OVERLAY0: Color = Color::Rgb(108, 112, 134);
pub const C_TEXT: Color = Color::Rgb(205, 214, 244);
pub const C_LAVENDER: Color = Color::Rgb(180, 190, 254);
pub const C_SAPPHIRE: Color = Color::Rgb(116, 199, 236);
pub const C_TEAL: Color = Color::Rgb(148, 226, 213);
pub const C_GREEN: Color = Color::Rgb(166, 227, 161);
pub const C_YELLOW: Color = Color::Rgb(249, 226, 175);
pub const C_PEACH: Color = Color::Rgb(250, 179, 135);
pub const C_RED: Color = Color::Rgb(243, 139, 168);
pub const C_MAUVE: Color = Color::Rgb(203, 166, 247);
pub const C_SUBTEXT: Color = Color::Rgb(166, 173, 200);