426 lines
16 KiB
Rust
426 lines
16 KiB
Rust
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,
|
|
prelude::Stylize,
|
|
};
|
|
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!["ember-tune 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::Min(4), // Cooling (Increased for multiple fans)
|
|
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);
|
|
|
|
if state.is_emergency {
|
|
draw_emergency_overlay(f, area, state);
|
|
}
|
|
}
|
|
|
|
fn draw_emergency_overlay(f: &mut Frame, area: Rect, state: &TelemetryState) {
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Double)
|
|
.border_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
|
|
.bg(Color::Black)
|
|
.title(" 🚨 EMERGENCY ABORT 🚨 ");
|
|
|
|
let area = centered_rect(60, 20, area);
|
|
let inner = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
let reason = state.emergency_reason.as_deref().unwrap_or("Unknown safety trigger");
|
|
let text = vec![
|
|
Line::from(vec![Span::styled("CRITICAL SAFETY LIMIT TRIGGERED", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))]),
|
|
Line::from(""),
|
|
Line::from(vec![Span::raw("Reason: "), Span::styled(reason, Style::default().fg(Color::Yellow))]),
|
|
Line::from(""),
|
|
Line::from("Hardware has been restored to safe defaults."),
|
|
Line::from("Exiting in 1 second..."),
|
|
];
|
|
|
|
f.render_widget(Paragraph::new(text).alignment(ratatui::layout::Alignment::Center), inner);
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(popup_layout[1])[1]
|
|
}
|
|
|
|
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(" EMBER-TUNE 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 mut lines = Vec::new();
|
|
|
|
// Line 1: Tier
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" Tier: ", Style::default().fg(C_LAVENDER)),
|
|
Span::styled(&state.fan_tier, Style::default().fg(C_TEAL)),
|
|
]));
|
|
|
|
// Line 2+: Fans
|
|
if state.fans.is_empty() {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" Fans: ", Style::default().fg(C_LAVENDER)),
|
|
Span::styled("N/A", Style::default().fg(C_SUBTEXT)),
|
|
]));
|
|
} else if state.fans.len() == 1 {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" Fan: ", Style::default().fg(C_LAVENDER)),
|
|
Span::styled(format!("{} RPM", state.fans[0]), Style::default().fg(C_TEXT)),
|
|
]));
|
|
} else {
|
|
for (i, rpm) in state.fans.iter().enumerate() {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!(" Fan {}: ", i + 1), Style::default().fg(C_LAVENDER)),
|
|
Span::styled(format!("{} RPM", rpm), Style::default().fg(C_TEXT)),
|
|
]));
|
|
}
|
|
}
|
|
|
|
f.render_widget(Paragraph::new(lines), 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
|
|
);
|
|
}
|