use crate::{
action::Action,
components::Component,
errors::Result,
state::AppState,
theme::Theme,
};
use ratatui::{prelude::*, widgets::*};
pub struct Dashboard<'a> {
state: &'a AppState,
}
impl<'a> Dashboard<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
fn render_cluster_summary(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Cluster Capacity")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
if let Some(summary) = &self.state.cluster_summary {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(block.inner(area));
let workers_text = vec![
Line::from(vec![
Span::styled("Active Workers", theme.muted_style()),
]),
Line::from(vec![
Span::styled(
format!("{}", summary.active_workers),
Style::default().fg(theme.accent).bold(),
),
]),
];
let workers_para = Paragraph::new(workers_text)
.alignment(Alignment::Center);
frame.render_widget(workers_para, chunks[0]);
let capacity = summary.total_capacity.unwrap_or(0);
let capacity_text = vec![
Line::from(vec![
Span::styled("Total Capacity", theme.muted_style()),
]),
Line::from(vec![
Span::styled(
format!("{}", capacity),
Style::default().fg(theme.accent).bold(),
),
]),
];
let capacity_para = Paragraph::new(capacity_text)
.alignment(Alignment::Center);
frame.render_widget(capacity_para, chunks[1]);
let utilization = summary.cluster_utilization_pct.unwrap_or(0.0);
let util_color = if utilization > 90.0 {
theme.error
} else if utilization > 70.0 {
theme.warning
} else {
theme.success
};
let util_text = vec![
Line::from(vec![
Span::styled("Utilization", theme.muted_style()),
]),
Line::from(vec![
Span::styled(
format!("{:.1}%", utilization),
Style::default().fg(util_color).bold(),
),
]),
];
let util_para = Paragraph::new(util_text)
.alignment(Alignment::Center);
frame.render_widget(util_para, chunks[2]);
let running = summary.total_running.unwrap_or(0);
let running_text = vec![
Line::from(vec![
Span::styled("Running Tasks", theme.muted_style()),
]),
Line::from(vec![
Span::styled(
format!("{}", running),
Style::default().fg(theme.accent).bold(),
),
]),
];
let running_para = Paragraph::new(running_text)
.alignment(Alignment::Center);
frame.render_widget(running_para, chunks[3]);
} else {
let placeholder = Paragraph::new("No cluster data available")
.style(theme.muted_style())
.alignment(Alignment::Center)
.block(block.clone());
frame.render_widget(placeholder, area);
return;
}
frame.render_widget(block, area);
}
fn render_task_status(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Task Status Distribution")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
if self.state.task_status_dist.is_empty() {
let placeholder = Paragraph::new("No task data available")
.style(theme.muted_style())
.alignment(Alignment::Center)
.block(block);
frame.render_widget(placeholder, area);
return;
}
let status_color = |status: &str| -> Color {
match status.to_lowercase().as_str() {
"total" => theme.accent,
"pending" => Color::Yellow,
"claimed" => Color::Cyan,
"running" => Color::Blue,
"completed" => theme.success,
"failed" => theme.error,
"cancelled" => theme.muted,
"expired" => Color::DarkGray,
_ => theme.text,
}
};
let rows: Vec<Row> = self
.state
.task_status_dist
.iter()
.map(|row| {
let color = status_color(&row.status);
let is_total = row.status.to_uppercase() == "TOTAL";
let status_text = row.status.to_uppercase();
let style = if is_total {
Style::default().fg(color).bold()
} else {
Style::default().fg(color)
};
Row::new(vec![
Cell::from(status_text),
Cell::from(format!("{}", row.count)),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[Constraint::Percentage(50), Constraint::Percentage(50)],
)
.header(
Row::new(vec!["Status", "Count"])
.style(Style::default().fg(theme.accent).bold())
.bottom_margin(1),
)
.block(block)
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(table, area);
}
fn render_utilization_trend(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Cluster Utilization Trend (1h)")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
if self.state.utilization_trend.is_empty() {
let placeholder = Paragraph::new("No trend data available")
.style(theme.muted_style())
.alignment(Alignment::Center)
.block(block);
frame.render_widget(placeholder, area);
return;
}
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(oldest) = self.state.utilization_trend.first() else {
return;
};
let Some(latest) = self.state.utilization_trend.last() else {
return;
};
let now = chrono::Utc::now();
let oldest_time = oldest.minute;
let time_span = (now - oldest_time).num_seconds() as f64;
let utilization_data: Vec<(f64, f64)> = self
.state
.utilization_trend
.iter()
.map(|p| {
let x = (p.minute - oldest_time).num_seconds() as f64;
let y = p.avg_utilization_pct.unwrap_or(0.0);
(x, y)
})
.collect();
let current_util = latest.avg_utilization_pct.unwrap_or(0.0);
let dataset = Dataset::default()
.name(format!("Utilization: {:.1}%", current_util))
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(theme.accent))
.data(&utilization_data);
let oldest_label = oldest_time.format("%H:%M").to_string();
let mid_time = oldest_time + chrono::Duration::seconds((time_span / 2.0) as i64);
let mid_label = mid_time.format("%H:%M").to_string();
let now_label = "now".to_string();
let x_axis = Axis::default()
.title("Time")
.style(Style::default().fg(theme.text))
.bounds([0.0, time_span])
.labels(vec![
Span::raw(oldest_label),
Span::raw(mid_label),
Span::raw(now_label),
]);
let y_axis = Axis::default()
.title("%")
.style(Style::default().fg(theme.text))
.bounds([0.0, 100.0])
.labels(vec![
Span::raw("0"),
Span::raw("50"),
Span::raw("100"),
]);
let chart = Chart::new(vec![dataset])
.x_axis(x_axis)
.y_axis(y_axis);
frame.render_widget(chart, inner);
}
fn render_workflow_summary(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Workflow Summary")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
if let Some(summary) = &self.state.workflow_summary {
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = vec![
Row::new(vec![
Cell::from("RUNNING"),
Cell::from(format!("{}", summary.running.unwrap_or(0))),
])
.style(Style::default().fg(Color::Blue)),
Row::new(vec![
Cell::from("PENDING"),
Cell::from(format!("{}", summary.pending.unwrap_or(0))),
])
.style(Style::default().fg(Color::Yellow)),
Row::new(vec![
Cell::from("PAUSED"),
Cell::from(format!("{}", summary.paused.unwrap_or(0))),
])
.style(Style::default().fg(Color::Magenta)),
Row::new(vec![
Cell::from("COMPLETED"),
Cell::from(format!("{}", summary.completed.unwrap_or(0))),
])
.style(Style::default().fg(theme.success)),
Row::new(vec![
Cell::from("FAILED"),
Cell::from(format!("{}", summary.failed.unwrap_or(0))),
])
.style(Style::default().fg(theme.error)),
Row::new(vec![
Cell::from("CANCELLED"),
Cell::from(format!("{}", summary.cancelled.unwrap_or(0))),
])
.style(Style::default().fg(theme.muted)),
Row::new(vec![
Cell::from("TOTAL"),
Cell::from(format!("{}", summary.total())),
])
.style(Style::default().fg(theme.accent).bold()),
];
let table = Table::new(
rows,
[Constraint::Percentage(60), Constraint::Percentage(40)],
)
.header(
Row::new(vec!["Status", "Count"])
.style(Style::default().fg(theme.accent).bold())
.bottom_margin(1),
)
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(table, inner);
} else {
let placeholder = Paragraph::new("No workflow data available")
.style(theme.muted_style())
.alignment(Alignment::Center)
.block(block);
frame.render_widget(placeholder, area);
}
}
fn render_alerts(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Active Alerts")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
let inner = block.inner(area);
frame.render_widget(block, area);
let total_alerts = self.state.overloaded_alerts.len() + self.state.stale_claims_alerts.len();
if total_alerts == 0 {
let placeholder = Paragraph::new("No active alerts")
.style(Style::default().fg(theme.success))
.alignment(Alignment::Center);
frame.render_widget(placeholder, inner);
return;
}
let mut lines = Vec::new();
for alert in &self.state.overloaded_alerts {
let alert_text = format!(
"⚠ Overloaded: {} ({}%) - CPU: {:.1}%, Mem: {:.1}%",
alert.worker_id,
alert.hostname,
alert.cpu_percent.unwrap_or(0.0),
alert.memory_percent.unwrap_or(0.0),
);
lines.push(Line::from(vec![
Span::styled("⚠ ", Style::default().fg(theme.warning)),
Span::styled(alert_text, Style::default().fg(theme.text)),
]));
}
for alert in &self.state.stale_claims_alerts {
let alert_text = format!(
"⚠ Stale Claims: {} ({}) - Running: {}, Claimed: {} (ratio: {:.2})",
alert.worker_id,
alert.hostname,
alert.tasks_running,
alert.tasks_claimed,
alert.claim_ratio.unwrap_or(0.0),
);
lines.push(Line::from(vec![
Span::styled("⚠ ", Style::default().fg(theme.warning)),
Span::styled(alert_text, Style::default().fg(theme.text)),
]));
}
let alerts_para = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.wrap(Wrap { trim: true });
frame.render_widget(alerts_para, inner);
}
}
impl<'a> Component for Dashboard<'a> {
fn update(&mut self, _action: Action) -> Result<Option<Action>> {
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) -> Result<()> {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Min(10), Constraint::Length(6), ])
.split(area);
self.render_cluster_summary(frame, main_chunks[0], theme);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(30), Constraint::Min(28), Constraint::Fill(1), ])
.split(main_chunks[1]);
self.render_task_status(frame, middle_chunks[0], theme);
self.render_workflow_summary(frame, middle_chunks[1], theme);
self.render_utilization_trend(frame, middle_chunks[2], theme);
self.render_alerts(frame, main_chunks[2], theme);
Ok(())
}
}