use std::cell::Cell;
use crate::errors::{Result, SyceError};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::prelude::{Position, Rect};
use sqlx::PgPool;
use tokio::sync::mpsc;
use crate::{
action::{Action, DataSource, DataUpdate, ListenerState, SearchMatch, Tab, TaskStatus, WorkflowStatus},
listener::NotifyListenerHandle,
state::{SearchHighlight, Toast, ToastIcon},
tui::NotifyBatch,
components::{
dashboard::Dashboard, error_modal::ErrorModal, help::HelpOverlay, maintenance::Maintenance,
search::SearchModal, status_bar::StatusBar, task_detail::TaskDetailPanel, task_list::TaskList,
tasks::Tasks, workers::Workers, workflow_detail::WorkflowDetailPanel, workflows::Workflows, Component,
},
db::queries,
state::AppState,
theme::{Theme, ThemeFlavor},
tui::{Event, Tui},
};
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::style::Style;
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
const PAGE_SIZE: usize = 10;
fn centered_rect(percent_x: u16, percent_y: u16, area: 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(area);
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 render_toast(frame: &mut ratatui::Frame, toast: &Toast, theme: &Theme) {
let area = frame.area();
let msg_len = toast.message.chars().count() as u16;
let toast_width = (msg_len + 6).min(40).max(20); let toast_height = 3;
let toast_x = area.width.saturating_sub(toast_width + 2);
let toast_y = area.height.saturating_sub(toast_height + 2);
let toast_area = Rect::new(toast_x, toast_y, toast_width, toast_height);
frame.render_widget(Clear, toast_area);
let (icon, border_color) = match toast.icon {
ToastIcon::Success => ("✓", theme.success),
ToastIcon::Info => ("ℹ", theme.accent),
ToastIcon::Warning => ("âš ", theme.warning),
ToastIcon::Error => ("✗", theme.error),
};
let opacity = if toast.ticks_remaining <= 2 {
theme.muted
} else {
theme.text
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.surface));
let content = format!(" {} {} ", icon, toast.message);
let paragraph = Paragraph::new(content)
.style(Style::default().fg(opacity).bg(theme.surface))
.alignment(Alignment::Center)
.block(block);
frame.render_widget(paragraph, toast_area);
}
pub struct App {
tick_rate: f64,
frame_rate: f64,
theme: Theme,
should_quit: bool,
should_suspend: bool,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
state: AppState,
pool: Option<PgPool>,
tick_counter: u64,
listener_handle: Option<NotifyListenerHandle>,
task_detail_fetch_inflight: bool,
workflow_detail_fetch_inflight: bool,
}
impl App {
pub async fn new(tick_rate: f64, frame_rate: f64, database_url: Option<String>) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let pool = if let Some(url) = database_url {
match PgPool::connect(&url).await {
Ok(pool) => {
eprintln!("✓ Connected to database");
Some(pool)
}
Err(e) => {
eprintln!("✗ Failed to connect to database: {}", e);
eprintln!(" Continuing without database (demo mode)");
None
}
}
} else {
eprintln!("No database URL provided. Running in demo mode.");
None
};
Ok(Self {
tick_rate,
frame_rate,
theme: Theme::new(ThemeFlavor::HorsiesDark),
should_quit: false,
should_suspend: false,
action_tx,
action_rx,
state: AppState::new(),
pool,
tick_counter: 0,
listener_handle: None,
task_detail_fetch_inflight: false,
workflow_detail_fetch_inflight: false,
})
}
fn send_action(tx: &mpsc::UnboundedSender<Action>, action: Action) -> Result<()> {
tx.send(action)
.map_err(|err| SyceError::Terminal(format!("failed to send action: {err}")))
}
fn clone_for_fetch(&self) -> FetchContext {
FetchContext {
pool: self.pool.clone(),
action_tx: self.action_tx.clone(),
}
}
fn start_listener(&mut self, event_tx: mpsc::Sender<Event>) {
if self.listener_handle.is_none() {
self.listener_handle = NotifyListenerHandle::spawn(self.pool.clone(), event_tx);
}
}
fn stop_listener(&mut self) {
if let Some(handle) = self.listener_handle.take() {
handle.stop();
}
self.state.listener_state = ListenerState::Disconnected;
}
fn handle_notify_refresh(&mut self, batch: NotifyBatch) {
if self.pool.is_none() {
return;
}
if batch.task_status && self.state.show_task_detail && !self.task_detail_fetch_inflight {
if let Some(task) = &self.state.task_detail {
self.task_detail_fetch_inflight = true;
let ctx = self.clone_for_fetch();
let task_id = task.id.clone();
tokio::spawn(async move {
ctx.fetch_task_detail(task_id).await;
});
}
}
if (batch.workflow_status || batch.task_status) && self.state.show_workflow_detail && !self.workflow_detail_fetch_inflight {
if let Some(workflow) = &self.state.workflow_detail {
self.workflow_detail_fetch_inflight = true;
let ctx = self.clone_for_fetch();
let workflow_id = workflow.id.clone();
tokio::spawn(async move {
ctx.fetch_workflow_detail(workflow_id).await;
});
}
}
match self.state.current_tab {
Tab::Dashboard => {
if batch.task_status || batch.workflow_status || batch.worker_state {
let ctx = self.clone_for_fetch();
tokio::spawn(async move {
ctx.fetch_dashboard_data(false).await;
});
}
}
Tab::Workers => {
if batch.worker_state {
let ctx = self.clone_for_fetch();
let worker_id = self.state.selected_worker_id.clone();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
ctx.fetch_workers_data(worker_id, &time_interval).await;
});
}
}
Tab::Tasks => {
if batch.task_status {
let ctx = self.clone_for_fetch();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
tokio::spawn(async move {
ctx.fetch_tasks_data(filter, retried_only).await;
});
}
}
Tab::Workflows => {
if batch.workflow_status || batch.task_status {
let ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
ctx.fetch_workflows_data(filter).await;
});
}
}
Tab::Maintenance => {
}
}
}
fn perform_search(&mut self) {
let query = self.state.search.query.to_lowercase();
if query.trim().is_empty() {
self.state.search.set_matches(vec![]);
return;
}
let matches = if self.state.show_task_detail {
self.search_task_detail_content(&query)
} else if self.state.show_workflow_detail {
self.search_workflow_detail_content(&query)
} else {
match self.state.current_tab {
Tab::Workers => self.search_workers(&query),
Tab::Tasks => self.search_tasks(&query),
Tab::Workflows => self.search_workflows(&query),
Tab::Dashboard => self.search_dashboard(&query),
Tab::Maintenance => vec![], }
};
self.state.search.set_matches(matches);
}
fn search_workers(&self, query: &str) -> Vec<SearchMatch> {
self.state
.worker_list
.iter()
.filter(|w| {
w.worker_id.to_lowercase().contains(query)
|| w.hostname.to_lowercase().contains(query)
})
.map(|w| {
let status = if w.tasks_running > 0 {
"Running".to_string()
} else {
"Idle".to_string()
};
SearchMatch::Worker {
worker_id: w.worker_id.clone(),
hostname: w.hostname.clone(),
status,
}
})
.collect()
}
fn search_tasks(&self, query: &str) -> Vec<SearchMatch> {
let mut matches = Vec::new();
for row in &self.state.task_aggregation {
if row.worker_id == "TOTAL" {
continue;
}
let worker_matches = row.worker_id.to_lowercase().contains(query);
if let Some(claimed_ids) = &row.claimed_task_ids {
for task_id in claimed_ids {
if task_id.to_lowercase().contains(query) || worker_matches {
matches.push(SearchMatch::Task {
task_id: task_id.clone(),
worker_id: row.worker_id.clone(),
status: "CLAIMED".to_string(),
});
}
}
}
if let Some(running_ids) = &row.running_task_ids {
for task_id in running_ids {
if task_id.to_lowercase().contains(query) || worker_matches {
matches.push(SearchMatch::Task {
task_id: task_id.clone(),
worker_id: row.worker_id.clone(),
status: "RUNNING".to_string(),
});
}
}
}
}
matches
}
fn search_workflows(&self, query: &str) -> Vec<SearchMatch> {
self.state
.workflow_list
.iter()
.filter(|w| {
w.id.to_lowercase().contains(query)
|| w.name.to_lowercase().contains(query)
})
.map(|w| SearchMatch::Workflow {
workflow_id: w.id.clone(),
name: w.name.clone(),
status: w.status.clone(),
})
.collect()
}
fn search_dashboard(&self, query: &str) -> Vec<SearchMatch> {
let mut matches = Vec::new();
for alert in &self.state.overloaded_alerts {
if alert.worker_id.to_lowercase().contains(query)
|| alert.hostname.to_lowercase().contains(query)
{
matches.push(SearchMatch::Worker {
worker_id: alert.worker_id.clone(),
hostname: alert.hostname.clone(),
status: "OVERLOADED".to_string(),
});
}
}
matches
}
fn search_task_detail_content(&self, query: &str) -> Vec<SearchMatch> {
let Some(task) = &self.state.task_detail else {
return vec![];
};
let panel = TaskDetailPanel::new(task, 0, None);
panel
.build_search_lines(&self.theme)
.into_iter()
.enumerate()
.filter_map(|(line_number, line)| {
if line.search_text.to_lowercase().contains(query) {
Some(SearchMatch::ModalLine {
line_number,
content: line.display,
})
} else {
None
}
})
.collect()
}
fn search_workflow_detail_content(&self, query: &str) -> Vec<SearchMatch> {
let Some(workflow) = &self.state.workflow_detail else {
return vec![];
};
let panel = WorkflowDetailPanel::new(workflow, &self.state.workflow_tasks, 0, None);
panel
.build_search_lines(&self.theme)
.into_iter()
.enumerate()
.filter_map(|(line_number, line)| {
if line.search_text.to_lowercase().contains(query) {
Some(SearchMatch::ModalLine {
line_number,
content: line.display,
})
} else {
None
}
})
.collect()
}
fn handle_search_confirm(&mut self) -> Result<()> {
let Some(selected_match) = self.state.search.get_selected_match().cloned() else {
return Ok(());
};
self.state.search.close();
match selected_match {
SearchMatch::Worker { worker_id, .. } => {
self.state.search_highlight = Some(SearchHighlight::new(worker_id.clone()));
if self.state.current_tab == Tab::Workers {
if let Some(idx) = self
.state
.worker_list
.iter()
.position(|w| w.worker_id == worker_id)
{
self.state.selected_worker_index = Some(idx);
self.state.selected_worker_id = Some(worker_id.clone());
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let time_interval =
self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx
.fetch_workers_data(Some(worker_id), &time_interval)
.await;
});
}
}
} else if self.state.current_tab == Tab::Dashboard {
Self::send_action(&self.action_tx, Action::SwitchTab(Tab::Workers))?;
self.state.selected_worker_id = Some(worker_id);
}
}
SearchMatch::Task {
task_id,
worker_id,
..
} => {
self.state.search_highlight = Some(SearchHighlight::new(task_id.clone()));
if let Some(row_idx) = self
.state
.task_aggregation
.iter()
.position(|r| r.worker_id == worker_id)
{
self.state.selected_task_index = Some(row_idx);
self.state.expanded_worker_index = Some(row_idx);
let task_ids = self.state.get_expanded_task_ids();
if let Some(task_idx) = task_ids.iter().position(|id| id == &task_id) {
self.state.selected_task_id_index = Some(task_idx);
Self::send_action(&self.action_tx, Action::OpenTaskDetail(task_id))?;
}
}
}
SearchMatch::Workflow { workflow_id, .. } => {
self.state.search_highlight = Some(SearchHighlight::new(workflow_id.clone()));
if let Some(idx) = self
.state
.workflow_list
.iter()
.position(|w| w.id == workflow_id)
{
self.state.selected_workflow_index = Some(idx);
Self::send_action(&self.action_tx, Action::OpenWorkflowDetail(workflow_id))?;
}
}
SearchMatch::ModalLine { line_number, .. } => {
self.state.search_highlight = Some(SearchHighlight::new(format!("line:{}", line_number)));
if self.state.show_task_detail {
self.state.task_detail_scroll = line_number as u16;
} else if self.state.show_workflow_detail {
self.state.workflow_detail_scroll = line_number as u16;
}
}
}
Ok(())
}
}
struct FetchContext {
pool: Option<PgPool>,
action_tx: mpsc::UnboundedSender<Action>,
}
impl FetchContext {
async fn fetch_dashboard_data(&self, show_loading: bool) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
if show_loading {
App::send_action(action_tx, Action::StartLoading(DataSource::ClusterSummary)).ok();
}
match queries::fetch_cluster_capacity_summary(pool).await {
Ok(summary) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::ClusterSummary(summary)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::ClusterSummary),
)
.ok();
}
}
if show_loading {
App::send_action(action_tx, Action::StartLoading(DataSource::WorkflowSummary)).ok();
}
match queries::fetch_workflow_summary(pool).await {
Ok(summary) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkflowSummary(summary)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::WorkflowSummary),
)
.ok();
}
}
if show_loading {
App::send_action(action_tx, Action::StartLoading(DataSource::TaskStatus)).ok();
}
match queries::fetch_task_status_view(pool).await {
Ok(rows) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::TaskStatusView(rows)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::TaskStatus),
)
.ok();
}
}
if show_loading {
App::send_action(action_tx, Action::StartLoading(DataSource::UtilizationTrend)).ok();
}
match queries::fetch_cluster_utilization_trend(pool).await {
Ok(points) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::UtilizationTrend(points)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::UtilizationTrend),
)
.ok();
}
}
if show_loading {
App::send_action(action_tx, Action::StartLoading(DataSource::Alerts)).ok();
}
match tokio::try_join!(
queries::fetch_overloaded_workers(pool),
queries::fetch_stale_claims(pool)
) {
Ok((overloaded, stale)) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::Alerts(overloaded, stale)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::Alerts),
)
.ok();
}
}
}
async fn fetch_workers_data(&self, selected_worker_id: Option<String>, time_window_interval: &str) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::WorkerList)).ok();
match queries::fetch_active_workers_list(pool).await {
Ok(workers) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkerList(workers)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::WorkerList),
)
.ok();
}
}
App::send_action(action_tx, Action::StartLoading(DataSource::DeadWorkers)).ok();
match queries::fetch_dead_workers(pool).await {
Ok(workers) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::DeadWorkers(workers)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::DeadWorkers),
)
.ok();
}
}
if let Some(worker_id) = selected_worker_id {
App::send_action(action_tx, Action::StartLoading(DataSource::WorkerDetails)).ok();
match tokio::try_join!(
queries::fetch_worker_uptime(pool),
queries::fetch_worker_queues(pool),
queries::fetch_worker_load(pool, time_window_interval)
) {
Ok((uptime_rows, queues_rows, load_points)) => {
if let Some(uptime) = uptime_rows.iter().find(|u| u.worker_id == worker_id) {
if let Some(queues) = queues_rows.iter().find(|q| q.worker_id == worker_id) {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkerDetails(
worker_id.clone(),
uptime.clone(),
queues.clone(),
)),
)
.ok();
}
}
let worker_load: Vec<_> = load_points
.into_iter()
.filter(|p| p.worker_id == worker_id)
.collect();
if !worker_load.is_empty() {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkerLoad(worker_load)),
)
.ok();
}
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::WorkerDetails),
)
.ok();
}
}
}
}
async fn fetch_tasks_data(&self, status_filter: Vec<&'static str>, retried_only: bool) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::TaskAggregation)).ok();
let result = if status_filter.is_empty() {
queries::fetch_task_aggregation(pool, retried_only).await
} else {
queries::fetch_filtered_task_aggregation(pool, &status_filter, retried_only).await
};
match result {
Ok(rows) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::TaskAggregation(rows)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::TaskAggregation),
)
.ok();
}
}
}
async fn fetch_task_list(
&self,
worker_id: Option<String>,
status_filter: Vec<&'static str>,
retried_only: bool,
name_filter: Vec<String>,
queue_filter: Vec<String>,
error_filter: Vec<String>,
) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::TaskListData)).ok();
match queries::fetch_distinct_filter_values(
pool,
worker_id.as_deref(),
&status_filter,
retried_only,
)
.await
{
Ok((names, queues, errors)) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::DistinctFilterValues(names, queues, errors)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::TaskListData),
)
.ok();
}
}
match queries::fetch_task_list_by_worker(
pool,
worker_id.as_deref(),
&status_filter,
retried_only,
&name_filter,
&queue_filter,
&error_filter,
)
.await
{
Ok(rows) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::TaskListLoaded(rows)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::TaskListData),
)
.ok();
}
}
}
async fn fetch_maintenance_data(&self) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::SnapshotAge)).ok();
match queries::fetch_snapshot_age_distribution(pool).await {
Ok(buckets) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::SnapshotAge(buckets)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::SnapshotAge),
)
.ok();
}
}
}
async fn fetch_task_detail(&self, task_id: String) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::TaskDetailData)).ok();
match queries::fetch_task_by_id(pool, &task_id).await {
Ok(Some(task)) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::TaskDetailLoaded(task)),
)
.ok();
}
Ok(None) => {
App::send_action(
action_tx,
Action::DataLoadError(
format!("Task {} not found", task_id),
DataSource::TaskDetailData,
),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::TaskDetailData),
)
.ok();
}
}
}
async fn fetch_workflows_data(&self, status_filter: Vec<&'static str>) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::WorkflowList)).ok();
let result = if status_filter.is_empty() {
queries::fetch_workflow_list(pool).await
} else {
queries::fetch_filtered_workflow_list(pool, &status_filter).await
};
match result {
Ok(rows) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkflowList(rows)),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::WorkflowList),
)
.ok();
}
}
}
async fn fetch_workflow_detail(&self, workflow_id: String) {
let Some(pool) = &self.pool else {
return;
};
let action_tx = &self.action_tx;
App::send_action(action_tx, Action::StartLoading(DataSource::WorkflowDetail)).ok();
match tokio::try_join!(
queries::fetch_workflow_by_id(pool, &workflow_id),
queries::fetch_workflow_tasks(pool, &workflow_id)
) {
Ok((Some(workflow), tasks)) => {
App::send_action(
action_tx,
Action::DataLoaded(DataUpdate::WorkflowDetailLoaded(workflow, tasks)),
)
.ok();
}
Ok((None, _)) => {
App::send_action(
action_tx,
Action::DataLoadError(
format!("Workflow {} not found", workflow_id),
DataSource::WorkflowDetail,
),
)
.ok();
}
Err(e) => {
App::send_action(
action_tx,
Action::DataLoadError(format!("{}", e), DataSource::WorkflowDetail),
)
.ok();
}
}
}
}
impl App {
pub async fn run(&mut self) -> Result<()> {
let mut tui = Tui::new()?
.mouse(true) .tick_rate(self.tick_rate)
.frame_rate(self.frame_rate);
tui.enter()?;
self.start_listener(tui.event_tx.clone());
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_dashboard_data(true).await; });
}
let action_tx = self.action_tx.clone();
loop {
self.handle_events(&mut tui).await?;
self.handle_actions(&mut tui)?;
if self.should_suspend {
self.stop_listener();
tui.suspend()?;
Self::send_action(&action_tx, Action::Resume)?;
Self::send_action(&action_tx, Action::ClearScreen)?;
tui.enter()?;
} else if self.should_quit {
tui.stop()?;
break;
}
}
self.stop_listener();
tui.exit()?;
Ok(())
}
async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> {
let Some(event) = tui.next_event().await else {
return Ok(());
};
let action_tx = self.action_tx.clone();
match event {
Event::Quit => Self::send_action(&action_tx, Action::Quit)?,
Event::Tick => Self::send_action(&action_tx, Action::Tick)?,
Event::Render => Self::send_action(&action_tx, Action::Render)?,
Event::Resize(x, y) => Self::send_action(&action_tx, Action::Resize(x, y))?,
Event::Key(key) => self.handle_key_event(key)?,
Event::Mouse(mouse) => {
let action = match mouse.kind {
MouseEventKind::ScrollUp => {
if self.state.search.active {
Some(Action::SearchSelectUp)
} else if self.state.show_task_detail {
Some(Action::ScrollTaskDetailUp)
} else if self.state.show_workflow_detail {
Some(Action::ScrollWorkflowDetailUp)
} else {
match self.state.current_tab {
Tab::Tasks => {
if self.state.expanded_worker_index.is_some() {
Some(Action::NavigateTaskIdUp)
} else {
Some(Action::NavigateTaskUp)
}
}
Tab::Workers => Some(Action::NavigateWorkerUp),
Tab::Workflows => Some(Action::NavigateWorkflowUp),
_ => None,
}
}
}
MouseEventKind::ScrollDown => {
if self.state.search.active {
Some(Action::SearchSelectDown)
} else if self.state.show_task_detail {
Some(Action::ScrollTaskDetailDown)
} else if self.state.show_workflow_detail {
Some(Action::ScrollWorkflowDetailDown)
} else {
match self.state.current_tab {
Tab::Tasks => {
if self.state.expanded_worker_index.is_some() {
Some(Action::NavigateTaskIdDown)
} else {
Some(Action::NavigateTaskDown)
}
}
Tab::Workers => Some(Action::NavigateWorkerDown),
Tab::Workflows => Some(Action::NavigateWorkflowDown),
_ => None,
}
}
}
MouseEventKind::Down(MouseButton::Left) => {
if self.state.search.active {
self.handle_search_mouse_click(mouse.column, mouse.row)
} else {
self.handle_mouse_scrollbar_click(mouse.column, mouse.row)
}
}
_ => None,
};
if let Some(action) = action {
Self::send_action(&action_tx, action)?;
}
}
Event::DbNotify(batch) => Self::send_action(&action_tx, Action::NotifyRefresh(batch))?,
Event::ListenerStateChanged(state) => Self::send_action(&action_tx, Action::ListenerStateChanged(state))?,
_ => {}
}
Ok(())
}
fn handle_mouse_scrollbar_click(&self, col: u16, row: u16) -> Option<Action> {
let pos = Position::new(col, row);
if self.state.show_task_detail {
if let Some(area) = self.state.task_detail_scrollbar_area {
if area.contains(pos) {
let max_scroll = self.state.task_detail_content_height.saturating_sub(area.height);
let relative_y = row.saturating_sub(area.y);
let scroll = if area.height <= 1 {
0
} else {
((relative_y as u32 * max_scroll as u32) / (area.height.saturating_sub(1)) as u32) as u16
};
return Some(Action::SetTaskDetailScroll(scroll));
}
}
}
if self.state.show_workflow_detail {
if let Some(area) = self.state.workflow_detail_scrollbar_area {
if area.contains(pos) {
let max_scroll = self.state.workflow_detail_content_height.saturating_sub(area.height);
let relative_y = row.saturating_sub(area.y);
let scroll = if area.height <= 1 {
0
} else {
((relative_y as u32 * max_scroll as u32) / (area.height.saturating_sub(1)) as u32) as u16
};
return Some(Action::SetWorkflowDetailScroll(scroll));
}
}
}
None
}
fn handle_search_mouse_click(&self, col: u16, row: u16) -> Option<Action> {
let pos = Position::new(col, row);
if let Some(area) = self.state.search.scrollbar_area {
if area.contains(pos) {
let total = self.state.search.matches.len();
if total == 0 {
return None;
}
let view = self.state.search.results_view_height.max(1);
let max_scroll = total.saturating_sub(view);
let relative_y = row.saturating_sub(area.y) as usize;
let scroll = if area.height <= 1 {
0
} else {
(relative_y * max_scroll) / area.height.saturating_sub(1) as usize
};
return Some(Action::SearchSetScroll(scroll));
}
}
if let Some(area) = self.state.search.results_area {
if area.contains(pos) {
let rel_row = row.saturating_sub(area.y) as usize;
let idx = self.state.search.scroll_offset + rel_row;
if idx < self.state.search.matches.len() {
return Some(Action::SearchSelectIndex(idx));
}
}
}
None
}
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
let action_tx = self.action_tx.clone();
if self.state.search.active {
let action = match key.code {
KeyCode::Esc => Some(Action::CloseSearch),
KeyCode::Enter => Some(Action::SearchConfirm),
KeyCode::Up => Some(Action::SearchSelectUp),
KeyCode::Down => Some(Action::SearchSelectDown),
KeyCode::PageUp => Some(Action::SearchSelectPageUp),
KeyCode::PageDown => Some(Action::SearchSelectPageDown),
KeyCode::Home => Some(Action::SearchSelectHome),
KeyCode::End => Some(Action::SearchSelectEnd),
KeyCode::Backspace => Some(Action::SearchBackspace),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchInput(c))
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchClear)
}
_ => None,
};
if let Some(action) = action {
Self::send_action(&action_tx, action)?;
}
return Ok(());
}
if self.state.sidebar_section != crate::state::SidebarSection::None {
let action = match key.code {
KeyCode::Esc => Some(Action::ExitSidebarSection),
KeyCode::Up | KeyCode::Char('k') => Some(Action::SidebarCursorUp),
KeyCode::Down | KeyCode::Char('j') => Some(Action::SidebarCursorDown),
KeyCode::Enter | KeyCode::Char(' ') => Some(Action::SidebarToggleFilter),
_ => None,
};
if let Some(action) = action {
Self::send_action(&action_tx, action)?;
}
return Ok(());
}
let action = match (key.code, key.modifiers) {
(KeyCode::Char('/'), _) => Some(Action::OpenSearch),
(KeyCode::Char('?'), _) => Some(Action::ToggleHelp),
(KeyCode::Char('q'), _) => Some(Action::Quit),
(KeyCode::Esc, _) => {
if self.state.show_error_modal {
Some(Action::CloseErrorModal)
} else if self.state.show_workflow_detail {
Some(Action::CloseWorkflowDetail)
} else if self.state.show_task_detail {
Some(Action::CloseTaskDetail)
} else if self.state.show_help {
Some(Action::ToggleHelp)
} else if self.state.sidebar_section != crate::state::SidebarSection::None {
Some(Action::ExitSidebarSection)
} else if self.state.task_list_active {
Some(Action::ExitTaskListView)
} else if self.state.expanded_worker_index.is_some() {
self.state.collapse_expanded();
None } else {
Some(Action::Quit)
}
}
(KeyCode::Char('c'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::Quit)
}
(KeyCode::Char('z'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::Suspend)
}
(KeyCode::Char('t'), _) => Some(Action::NextTheme),
(KeyCode::Char('1'), _) => Some(Action::SwitchTab(Tab::Dashboard)),
(KeyCode::Char('2'), _) => Some(Action::SwitchTab(Tab::Workers)),
(KeyCode::Char('3'), _) => Some(Action::SwitchTab(Tab::Tasks)),
(KeyCode::Char('4'), _) => Some(Action::SwitchTab(Tab::Workflows)),
(KeyCode::Char('5'), _) => Some(Action::SwitchTab(Tab::Maintenance)),
(KeyCode::Char('r'), _) if self.state.current_tab != Tab::Workflows && self.state.current_tab != Tab::Tasks => Some(Action::RefreshCurrentTab),
#[cfg(debug_assertions)]
(KeyCode::Char('e'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::DataLoadError(
"Test error message from cluster summary".to_string(),
DataSource::ClusterSummary,
))
}
#[cfg(debug_assertions)]
(KeyCode::Char('l'), _) => Some(Action::StartLoading(DataSource::WorkerList)),
(KeyCode::Char('e'), _) if !self.state.errors.is_empty() && !self.state.show_error_modal => {
Some(Action::OpenErrorModal)
}
(KeyCode::Char('y'), _) if self.state.show_error_modal => {
Some(Action::CopyErrorToClipboard)
}
(KeyCode::Char('c'), _) if self.state.show_error_modal => {
Some(Action::ClearAllErrors)
}
(KeyCode::Up, _) if self.state.current_tab == Tab::Workers => {
Some(Action::NavigateWorkerUp)
}
(KeyCode::Down, _) if self.state.current_tab == Tab::Workers => {
Some(Action::NavigateWorkerDown)
}
(KeyCode::Char('['), _) if self.state.current_tab == Tab::Workers => {
Some(Action::CycleTimeWindowBackward)
}
(KeyCode::Char(']'), _) if self.state.current_tab == Tab::Workers => {
Some(Action::CycleTimeWindowForward)
}
(KeyCode::Up | KeyCode::Char('k'), _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
Some(Action::NavigateTaskListUp)
}
(KeyCode::Down | KeyCode::Char('j'), _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
Some(Action::NavigateTaskListDown)
}
(KeyCode::Enter, _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
self.state.get_selected_task_list_id().map(Action::OpenTaskDetail)
}
(KeyCode::Char('m'), _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
Some(Action::EnterSidebarSection(crate::state::SidebarSection::TaskNames))
}
(KeyCode::Char('u'), _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
Some(Action::EnterSidebarSection(crate::state::SidebarSection::Queues))
}
(KeyCode::Char('d'), _) if self.state.current_tab == Tab::Tasks && self.state.task_list_active && !self.state.show_task_detail => {
Some(Action::EnterSidebarSection(crate::state::SidebarSection::Errors))
}
(KeyCode::Char('p'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Pending))
}
(KeyCode::Char('c'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Claimed))
}
(KeyCode::Char('r'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Running))
}
(KeyCode::Char('o'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Completed))
}
(KeyCode::Char('f'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Failed))
}
(KeyCode::Char('x'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Cancelled))
}
(KeyCode::Char('e'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleTaskStatusFilter(TaskStatus::Expired))
}
(KeyCode::Char('a'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::SelectAllTaskStatuses)
}
(KeyCode::Char('n'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ClearTaskStatuses)
}
(KeyCode::Char('i'), _) if self.state.current_tab == Tab::Tasks && !self.state.show_task_detail => {
Some(Action::ToggleRetriedFilter)
}
(KeyCode::Up | KeyCode::Char('k'), _) if self.state.current_tab == Tab::Tasks && !self.state.task_list_active && !self.state.show_task_detail => {
if self.state.expanded_worker_index.is_some() {
Some(Action::NavigateTaskIdUp)
} else {
Some(Action::NavigateTaskUp)
}
}
(KeyCode::Down | KeyCode::Char('j'), _) if self.state.current_tab == Tab::Tasks && !self.state.task_list_active && !self.state.show_task_detail => {
if self.state.expanded_worker_index.is_some() {
Some(Action::NavigateTaskIdDown)
} else {
Some(Action::NavigateTaskDown)
}
}
(KeyCode::Enter, _) if self.state.current_tab == Tab::Tasks && !self.state.task_list_active && !self.state.show_task_detail => {
self.state.selected_task_index
.and_then(|idx| self.state.task_aggregation.get(idx))
.map(|row| {
if row.worker_id == "TOTAL" {
Action::EnterTaskListView(None)
} else {
Action::EnterTaskListView(Some(row.worker_id.clone()))
}
})
}
(KeyCode::Up, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailUp)
}
(KeyCode::Down, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailDown)
}
(KeyCode::PageUp, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailPageUp)
}
(KeyCode::PageDown, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailPageDown)
}
(KeyCode::Home, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailHome)
}
(KeyCode::End, _) if self.state.show_task_detail => {
Some(Action::ScrollTaskDetailEnd)
}
(KeyCode::Char('y'), _) if self.state.show_task_detail => {
Some(Action::CopyTaskToClipboard)
}
(KeyCode::Char(']'), _) if self.state.show_task_detail => {
Some(Action::NavigateTaskDetailNext)
}
(KeyCode::Char('['), _) if self.state.show_task_detail => {
Some(Action::NavigateTaskDetailPrev)
}
(KeyCode::Up, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailUp)
}
(KeyCode::Down, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailDown)
}
(KeyCode::PageUp, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailPageUp)
}
(KeyCode::PageDown, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailPageDown)
}
(KeyCode::Home, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailHome)
}
(KeyCode::End, _) if self.state.show_workflow_detail => {
Some(Action::ScrollWorkflowDetailEnd)
}
(KeyCode::Char('y'), _) if self.state.show_workflow_detail => {
Some(Action::CopyWorkflowToClipboard)
}
(KeyCode::Char(']'), _) if self.state.show_workflow_detail => {
Some(Action::NavigateWorkflowDetailNext)
}
(KeyCode::Char('['), _) if self.state.show_workflow_detail => {
Some(Action::NavigateWorkflowDetailPrev)
}
(KeyCode::Char('p'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Pending))
}
(KeyCode::Char('r'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Running))
}
(KeyCode::Char('o'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Completed))
}
(KeyCode::Char('f'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Failed))
}
(KeyCode::Char('u'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Paused))
}
(KeyCode::Char('x'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ToggleWorkflowStatusFilter(WorkflowStatus::Cancelled))
}
(KeyCode::Char('a'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::SelectAllWorkflowStatuses)
}
(KeyCode::Char('n'), _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::ClearWorkflowStatuses)
}
(KeyCode::Up, _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::NavigateWorkflowUp)
}
(KeyCode::Down, _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
Some(Action::NavigateWorkflowDown)
}
(KeyCode::Enter, _) if self.state.current_tab == Tab::Workflows && !self.state.show_workflow_detail => {
self.state.get_selected_workflow_id().map(Action::OpenWorkflowDetail)
}
(KeyCode::PageUp, _) if !self.state.show_task_detail && !self.state.show_workflow_detail => {
Some(Action::NavigatePageUp)
}
(KeyCode::PageDown, _) if !self.state.show_task_detail && !self.state.show_workflow_detail => {
Some(Action::NavigatePageDown)
}
(KeyCode::Home, _) if !self.state.show_task_detail && !self.state.show_workflow_detail => {
Some(Action::NavigateHome)
}
(KeyCode::End, _) if !self.state.show_task_detail && !self.state.show_workflow_detail => {
Some(Action::NavigateEnd)
}
_ => None,
};
if let Some(action) = action {
Self::send_action(&action_tx, action)?;
}
Ok(())
}
fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> {
while let Ok(action) = self.action_rx.try_recv() {
match action {
Action::Tick => {
self.tick_counter += 1;
if let Some(toast) = &mut self.state.toast {
if toast.tick() {
self.state.toast = None;
}
}
if let Some(highlight) = &mut self.state.search_highlight {
if highlight.tick() {
self.state.search_highlight = None;
}
}
let listener_connected = self.state.listener_state == ListenerState::Connected;
let dashboard_interval = if listener_connected { 60 } else { 8 };
let workers_interval = if listener_connected { 60 } else { 12 };
let tasks_interval = if listener_connected { 60 } else { 20 };
let workflows_interval = if listener_connected { 60 } else { 20 };
let maintenance_interval: u64 = 120;
if self.state.current_tab == Tab::Dashboard
&& self.pool.is_some()
&& self.tick_counter % dashboard_interval == 0
{
let app_clone = self.clone_for_fetch();
tokio::spawn(async move {
app_clone.fetch_dashboard_data(false).await;
});
}
if self.state.current_tab == Tab::Workers
&& self.pool.is_some()
&& self.tick_counter % workers_interval == 0
{
let app_clone = self.clone_for_fetch();
let selected_worker = self.state.selected_worker_id.clone();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
app_clone.fetch_workers_data(selected_worker, &time_interval).await;
});
}
if self.state.current_tab == Tab::Tasks
&& self.pool.is_some()
&& self.tick_counter % tasks_interval == 0
{
if self.state.task_list_active {
let app_clone = self.clone_for_fetch();
let worker_id = self.state.task_list_worker_id.clone();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
let name_f = self.state.selected_task_names_sql();
let queue_f = self.state.selected_queues_sql();
let error_f = self.state.selected_errors_sql();
tokio::spawn(async move {
app_clone.fetch_task_list(worker_id, filter, retried_only, name_f, queue_f, error_f).await;
});
} else {
let app_clone = self.clone_for_fetch();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
tokio::spawn(async move {
app_clone.fetch_tasks_data(filter, retried_only).await;
});
}
}
if self.state.current_tab == Tab::Maintenance
&& self.pool.is_some()
&& self.tick_counter % maintenance_interval == 0
{
let app_clone = self.clone_for_fetch();
tokio::spawn(async move {
app_clone.fetch_maintenance_data().await;
});
}
if self.state.current_tab == Tab::Workflows
&& self.pool.is_some()
&& self.tick_counter % workflows_interval == 0
{
let app_clone = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
app_clone.fetch_workflows_data(filter).await;
});
}
}
Action::Quit => {
self.stop_listener();
self.should_quit = true;
}
Action::Suspend => {
self.stop_listener();
self.should_suspend = true;
}
Action::Resume => {
self.should_suspend = false;
self.start_listener(tui.event_tx.clone());
}
Action::ClearScreen => tui.terminal.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
Action::NextTheme => {
self.theme = self.theme.next();
self.rebuild_detail_caches();
}
Action::ToggleHelp => {
self.state.show_help = !self.state.show_help;
}
Action::Error(msg) => {
eprintln!("Error: {}", msg);
}
Action::SwitchTab(tab) => {
self.state.current_tab = tab;
if self.pool.is_some() {
match self.state.current_tab {
Tab::Dashboard => {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_dashboard_data(true).await; });
}
Tab::Workers => {
let fetch_ctx = self.clone_for_fetch();
let selected_worker = self.state.selected_worker_id.clone();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(selected_worker, &time_interval).await;
});
}
Tab::Tasks => {
self.state.ensure_task_selection();
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
tokio::spawn(async move {
fetch_ctx.fetch_tasks_data(filter, retried_only).await;
});
}
Tab::Maintenance => {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_maintenance_data().await;
});
}
Tab::Workflows => {
self.state.ensure_workflow_selection();
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
fetch_ctx.fetch_workflows_data(filter).await;
});
}
}
}
}
Action::NavigateWorkerUp => {
self.state.select_worker_up();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Action::NavigateWorkerDown => {
self.state.select_worker_down();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Action::CycleTimeWindowForward => {
self.state.selected_time_window = self.state.selected_time_window.next();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Action::CycleTimeWindowBackward => {
self.state.selected_time_window = self.state.selected_time_window.prev();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Action::NavigateTaskUp => {
self.state.select_task_up();
}
Action::NavigateTaskDown => {
self.state.select_task_down();
}
Action::OpenTaskDetail(task_id) => {
self.state.task_detail_scroll = 0;
self.state.task_detail = None;
self.state.task_detail_cached_lines.clear();
self.state.show_task_detail = true;
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_task_detail(task_id).await;
});
}
}
Action::CloseTaskDetail => {
self.state.show_task_detail = false;
self.state.task_detail = None;
self.state.task_detail_cached_lines.clear();
self.state.task_detail_scroll = 0;
self.state.task_detail_scrollbar_area = None;
self.state.task_detail_content_height = 0;
}
Action::ScrollTaskDetailUp => {
self.state.task_detail_scroll = self.state.task_detail_scroll.saturating_sub(1);
}
Action::ScrollTaskDetailDown => {
self.state.task_detail_scroll = self.state.task_detail_scroll.saturating_add(1);
}
Action::ScrollTaskDetailPageUp => {
let page = self.state.task_detail_visible_height.max(1);
self.state.task_detail_scroll = self.state.task_detail_scroll.saturating_sub(page);
}
Action::ScrollTaskDetailPageDown => {
let page = self.state.task_detail_visible_height.max(1);
self.state.task_detail_scroll = self.state.task_detail_scroll.saturating_add(page);
}
Action::ScrollTaskDetailHome => {
self.state.task_detail_scroll = 0;
}
Action::ScrollTaskDetailEnd => {
self.state.task_detail_scroll = self.state.task_detail_content_height;
}
Action::SetTaskDetailScroll(pos) => {
self.state.task_detail_scroll = pos;
}
Action::CopyTaskToClipboard => {
if let Some(task) = &self.state.task_detail {
let json = task.to_clipboard_json();
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&json) {
Self::send_action(
&self.action_tx,
Action::Error(format!("Failed to copy: {}", e)),
)?;
} else {
self.state.toast = Some(Toast::success("Copied to clipboard"));
}
}
Err(e) => {
Self::send_action(
&self.action_tx,
Action::Error(format!("Clipboard unavailable: {}", e)),
)?;
}
}
}
}
Action::NavigateTaskDetailNext => {
if self.state.show_task_detail && self.state.expanded_worker_index.is_some() {
if self.state.select_task_id_down() {
if let Some(task_id) = self.state.get_selected_task_id() {
self.state.task_detail_scroll = 0;
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_task_detail(task_id).await;
});
}
}
}
}
Action::NavigateTaskDetailPrev => {
if self.state.show_task_detail && self.state.expanded_worker_index.is_some() {
if let Some(idx) = self.state.selected_task_id_index {
if idx > 0 {
self.state.selected_task_id_index = Some(idx - 1);
if let Some(task_id) = self.state.get_selected_task_id() {
self.state.task_detail_scroll = 0;
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_task_detail(task_id).await;
});
}
}
}
}
}
Action::OpenErrorModal => {
if !self.state.errors.is_empty() {
self.state.show_error_modal = true;
}
}
Action::CloseErrorModal => {
self.state.show_error_modal = false;
}
Action::CopyErrorToClipboard => {
if !self.state.errors.is_empty() {
let error_text: String = self.state.errors.iter()
.map(|(source, msg)| format!("{}: {}", source, msg))
.collect::<Vec<_>>()
.join("\n\n");
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&error_text) {
Self::send_action(
&self.action_tx,
Action::Error(format!("Failed to copy: {}", e)),
)?;
} else {
self.state.toast = Some(Toast::success("Errors copied"));
}
}
Err(e) => {
Self::send_action(
&self.action_tx,
Action::Error(format!("Clipboard unavailable: {}", e)),
)?;
}
}
}
}
Action::ClearAllErrors => {
self.state.errors.clear();
self.state.show_error_modal = false;
}
Action::ToggleTaskStatusFilter(status) => {
self.state.task_status_filter.toggle(status);
self.state.collapse_expanded();
Self::send_action(&self.action_tx, Action::RefreshTasks)?;
}
Action::SelectAllTaskStatuses => {
if self.state.task_status_filter.selected.len() == TaskStatus::all().len() {
self.state.task_status_filter.clear();
self.state.collapse_expanded();
if !self.state.task_list_active {
self.state.task_aggregation.clear();
} else {
self.state.task_list_rows.clear();
}
} else {
self.state.task_status_filter.select_all();
self.state.collapse_expanded();
Self::send_action(&self.action_tx, Action::RefreshTasks)?;
}
}
Action::ClearTaskStatuses => {
if self.state.task_status_filter.selected.is_empty() {
self.state.task_status_filter.select_all();
self.state.collapse_expanded();
Self::send_action(&self.action_tx, Action::RefreshTasks)?;
} else {
self.state.task_status_filter.clear();
self.state.collapse_expanded();
if !self.state.task_list_active {
self.state.task_aggregation.clear();
} else {
self.state.task_list_rows.clear();
}
}
}
Action::ToggleRetriedFilter => {
self.state.retried_only_filter = !self.state.retried_only_filter;
self.state.collapse_expanded();
Self::send_action(&self.action_tx, Action::RefreshTasks)?;
}
Action::ToggleTaskRow => {
self.state.toggle_expand_selected();
}
Action::NavigateTaskIdUp => {
self.state.select_task_id_up();
}
Action::NavigateTaskIdDown => {
self.state.select_task_id_down();
}
Action::EnterTaskListView(worker_id) => {
self.state.enter_task_list(worker_id.clone());
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
let name_f = self.state.selected_task_names_sql();
let queue_f = self.state.selected_queues_sql();
let error_f = self.state.selected_errors_sql();
let wid = worker_id;
tokio::spawn(async move {
fetch_ctx.fetch_task_list(wid, filter, retried_only, name_f, queue_f, error_f).await;
});
}
}
Action::ExitTaskListView => {
self.state.exit_task_list();
}
Action::NavigateTaskListUp => {
self.state.select_task_list_up();
}
Action::NavigateTaskListDown => {
self.state.select_task_list_down();
}
Action::RefreshTaskList => {
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let worker_id = self.state.task_list_worker_id.clone();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
let name_f = self.state.selected_task_names_sql();
let queue_f = self.state.selected_queues_sql();
let error_f = self.state.selected_errors_sql();
tokio::spawn(async move {
fetch_ctx.fetch_task_list(worker_id, filter, retried_only, name_f, queue_f, error_f).await;
});
}
}
Action::EnterSidebarSection(section) => {
self.state.enter_sidebar_section(section);
}
Action::ExitSidebarSection => {
self.state.exit_sidebar_section();
}
Action::SidebarCursorUp => {
self.state.sidebar_cursor_up();
}
Action::SidebarCursorDown => {
self.state.sidebar_cursor_down();
}
Action::SidebarToggleFilter => {
if self.state.toggle_sidebar_filter() {
Self::send_action(&self.action_tx, Action::RefreshTasks)?;
}
}
Action::NavigatePageUp => {
match self.state.current_tab {
Tab::Tasks => {
if self.state.task_list_active {
self.state.select_task_list_page_up(PAGE_SIZE);
} else if self.state.expanded_worker_index.is_some() {
self.state.select_task_id_page_up(PAGE_SIZE);
} else {
self.state.select_task_page_up(PAGE_SIZE);
}
}
Tab::Workers => {
self.state.select_worker_page_up(PAGE_SIZE);
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Tab::Workflows => self.state.select_workflow_page_up(PAGE_SIZE),
_ => {}
}
}
Action::NavigatePageDown => {
match self.state.current_tab {
Tab::Tasks => {
if self.state.task_list_active {
self.state.select_task_list_page_down(PAGE_SIZE);
} else if self.state.expanded_worker_index.is_some() {
self.state.select_task_id_page_down(PAGE_SIZE);
} else {
self.state.select_task_page_down(PAGE_SIZE);
}
}
Tab::Workers => {
self.state.select_worker_page_down(PAGE_SIZE);
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Tab::Workflows => self.state.select_workflow_page_down(PAGE_SIZE),
_ => {}
}
}
Action::NavigateHome => {
match self.state.current_tab {
Tab::Tasks => {
if self.state.task_list_active {
self.state.select_task_list_home();
} else {
self.state.select_task_home();
}
}
Tab::Workers => {
self.state.select_worker_home();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Tab::Workflows => self.state.select_workflow_home(),
_ => {}
}
}
Action::NavigateEnd => {
match self.state.current_tab {
Tab::Tasks => {
if self.state.task_list_active {
self.state.select_task_list_end();
} else {
self.state.select_task_end();
}
}
Tab::Workers => {
self.state.select_worker_end();
if self.pool.is_some() {
if let Some(worker_id) = self.state.selected_worker_id.clone() {
let fetch_ctx = self.clone_for_fetch();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(Some(worker_id), &time_interval).await;
});
}
}
}
Tab::Workflows => self.state.select_workflow_end(),
_ => {}
}
}
Action::RefreshCurrentTab => {
let refresh_action = match self.state.current_tab {
Tab::Dashboard => Action::RefreshDashboard,
Tab::Workers => Action::RefreshWorkers,
Tab::Tasks => Action::RefreshTasks,
Tab::Maintenance => Action::RefreshMaintenance,
Tab::Workflows => Action::RefreshWorkflows,
};
Self::send_action(&self.action_tx, refresh_action)?;
}
Action::RefreshDashboard => {
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_dashboard_data(true).await; });
}
}
Action::RefreshWorkers => {
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let selected_worker = self.state.selected_worker_id.clone();
let time_interval = self.state.selected_time_window.interval().to_string();
tokio::spawn(async move {
fetch_ctx.fetch_workers_data(selected_worker, &time_interval).await;
});
}
}
Action::RefreshTasks => {
if self.pool.is_some() {
if self.state.task_list_active {
let fetch_ctx = self.clone_for_fetch();
let worker_id = self.state.task_list_worker_id.clone();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
let name_f = self.state.selected_task_names_sql();
let queue_f = self.state.selected_queues_sql();
let error_f = self.state.selected_errors_sql();
tokio::spawn(async move {
fetch_ctx.fetch_task_list(worker_id, filter, retried_only, name_f, queue_f, error_f).await;
});
} else {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.task_status_filter.to_sql_values();
let retried_only = self.state.retried_only_filter;
tokio::spawn(async move {
fetch_ctx.fetch_tasks_data(filter, retried_only).await;
});
}
}
}
Action::RefreshMaintenance => {
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_maintenance_data().await;
});
}
}
Action::RefreshWorkflows => {
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
fetch_ctx.fetch_workflows_data(filter).await;
});
}
}
Action::NavigateWorkflowUp => {
self.state.select_workflow_up();
}
Action::NavigateWorkflowDown => {
self.state.select_workflow_down();
}
Action::OpenWorkflowDetail(workflow_id) => {
self.state.workflow_detail_scroll = 0;
self.state.workflow_detail = None;
self.state.workflow_tasks.clear();
self.state.workflow_detail_cached_lines.clear();
self.state.show_workflow_detail = true;
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_workflow_detail(workflow_id).await;
});
}
}
Action::CloseWorkflowDetail => {
self.state.show_workflow_detail = false;
self.state.workflow_detail = None;
self.state.workflow_tasks.clear();
self.state.workflow_detail_cached_lines.clear();
self.state.workflow_detail_scroll = 0;
self.state.workflow_detail_scrollbar_area = None;
self.state.workflow_detail_content_height = 0;
}
Action::ScrollWorkflowDetailUp => {
self.state.workflow_detail_scroll = self.state.workflow_detail_scroll.saturating_sub(1);
}
Action::ScrollWorkflowDetailDown => {
self.state.workflow_detail_scroll = self.state.workflow_detail_scroll.saturating_add(1);
}
Action::ScrollWorkflowDetailPageUp => {
let page = self.state.workflow_detail_visible_height.max(1);
self.state.workflow_detail_scroll = self.state.workflow_detail_scroll.saturating_sub(page);
}
Action::ScrollWorkflowDetailPageDown => {
let page = self.state.workflow_detail_visible_height.max(1);
self.state.workflow_detail_scroll = self.state.workflow_detail_scroll.saturating_add(page);
}
Action::ScrollWorkflowDetailHome => {
self.state.workflow_detail_scroll = 0;
}
Action::ScrollWorkflowDetailEnd => {
self.state.workflow_detail_scroll = self.state.workflow_detail_content_height;
}
Action::SetWorkflowDetailScroll(pos) => {
self.state.workflow_detail_scroll = pos;
}
Action::NavigateWorkflowDetailNext => {
if self.state.show_workflow_detail {
self.state.select_workflow_down();
if let Some(wf_id) = self.state.get_selected_workflow_id() {
self.state.workflow_detail_scroll = 0;
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_workflow_detail(wf_id).await;
});
}
}
}
Action::NavigateWorkflowDetailPrev => {
if self.state.show_workflow_detail {
self.state.select_workflow_up();
if let Some(wf_id) = self.state.get_selected_workflow_id() {
self.state.workflow_detail_scroll = 0;
let fetch_ctx = self.clone_for_fetch();
tokio::spawn(async move {
fetch_ctx.fetch_workflow_detail(wf_id).await;
});
}
}
}
Action::CopyWorkflowToClipboard => {
if let Some(workflow) = &self.state.workflow_detail {
let json = workflow.to_clipboard_json(&self.state.workflow_tasks);
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&json) {
Self::send_action(
&self.action_tx,
Action::Error(format!("Failed to copy: {}", e)),
)?;
} else {
self.state.toast = Some(Toast::success("Copied to clipboard"));
}
}
Err(e) => {
Self::send_action(
&self.action_tx,
Action::Error(format!("Clipboard unavailable: {}", e)),
)?;
}
}
}
}
Action::ToggleWorkflowStatusFilter(status) => {
self.state.workflow_status_filter.toggle(status);
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
fetch_ctx.fetch_workflows_data(filter).await;
});
}
}
Action::SelectAllWorkflowStatuses => {
if self.state.workflow_status_filter.selected.len() == WorkflowStatus::all().len() {
self.state.workflow_status_filter.clear();
self.state.workflow_list.clear();
} else {
self.state.workflow_status_filter.select_all();
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
fetch_ctx.fetch_workflows_data(filter).await;
});
}
}
}
Action::ClearWorkflowStatuses => {
if self.state.workflow_status_filter.selected.is_empty() {
self.state.workflow_status_filter.select_all();
if self.pool.is_some() {
let fetch_ctx = self.clone_for_fetch();
let filter = self.state.workflow_status_filter.to_sql_values();
tokio::spawn(async move {
fetch_ctx.fetch_workflows_data(filter).await;
});
}
} else {
self.state.workflow_status_filter.clear();
self.state.workflow_list.clear();
}
}
Action::StartLoading(source) => {
self.state.set_loading(source, true);
}
Action::DataLoaded(update) => {
self.handle_data_update(update);
}
Action::DataLoadError(error, source) => {
match source {
DataSource::TaskDetailData => self.task_detail_fetch_inflight = false,
DataSource::WorkflowDetail => self.workflow_detail_fetch_inflight = false,
_ => {}
}
self.state.set_error(source, error);
}
Action::OpenSearch => {
self.state.search.open();
self.perform_search();
}
Action::CloseSearch => {
self.state.search.close();
}
Action::SearchInput(c) => {
self.state.search.insert_char(c);
self.perform_search();
}
Action::SearchBackspace => {
self.state.search.backspace();
self.perform_search();
}
Action::SearchClear => {
self.state.search.clear_query();
}
Action::SearchSelectUp => {
self.state.search.select_up();
}
Action::SearchSelectDown => {
self.state.search.select_down();
}
Action::SearchSelectPageUp => {
self.state.search.select_page_up();
}
Action::SearchSelectPageDown => {
self.state.search.select_page_down();
}
Action::SearchSelectHome => {
self.state.search.select_home();
}
Action::SearchSelectEnd => {
self.state.search.select_end();
}
Action::SearchSetScroll(offset) => {
self.state.search.set_scroll_offset(offset);
}
Action::SearchSelectIndex(idx) => {
self.state.search.select_index(idx);
}
Action::SearchConfirm => {
self.handle_search_confirm()?;
}
Action::NotifyRefresh(batch) => {
self.handle_notify_refresh(batch);
}
Action::ListenerStateChanged(state) => {
let was_connected = self.state.listener_state == ListenerState::Connected;
self.state.listener_state = state;
match state {
ListenerState::Connected if !was_connected => {
self.state.toast = Some(Toast::info("Real-time updates active"));
}
ListenerState::Reconnecting => {
self.state.toast = Some(Toast {
message: "Reconnecting to database...".into(),
icon: ToastIcon::Warning,
ticks_remaining: 12,
});
}
_ => {}
}
}
}
}
Ok(())
}
fn handle_data_update(&mut self, update: DataUpdate) {
match update {
DataUpdate::ClusterSummary(summary) => {
self.state.cluster_summary = Some(summary);
self.state.set_loading(DataSource::ClusterSummary, false);
}
DataUpdate::TaskStatusView(rows) => {
self.state.task_status_dist = rows;
self.state.set_loading(DataSource::TaskStatus, false);
}
DataUpdate::UtilizationTrend(points) => {
self.state.utilization_trend = points;
self.state.set_loading(DataSource::UtilizationTrend, false);
}
DataUpdate::Alerts(overloaded, stale) => {
self.state.overloaded_alerts = overloaded;
self.state.stale_claims_alerts = stale;
self.state.set_loading(DataSource::Alerts, false);
}
DataUpdate::WorkerList(workers) => {
self.state.worker_list = workers;
self.state.set_loading(DataSource::WorkerList, false);
}
DataUpdate::WorkerDetails(worker_id, uptime, queues) => {
self.state.worker_uptime.insert(worker_id.clone(), uptime);
self.state.worker_queues.insert(worker_id, queues);
self.state.set_loading(DataSource::WorkerDetails, false);
}
DataUpdate::WorkerLoad(load_points) => {
if let Some(worker_id) = load_points.first().map(|p| p.worker_id.clone()) {
self.state.worker_load_history.insert(worker_id, load_points);
}
self.state.set_loading(DataSource::WorkerDetails, false);
}
DataUpdate::TaskAggregation(rows) => {
self.state.set_task_aggregation(rows);
self.state.set_loading(DataSource::TaskAggregation, false);
}
DataUpdate::SnapshotAge(buckets) => {
self.state.snapshot_age_dist = buckets;
self.state.set_loading(DataSource::SnapshotAge, false);
}
DataUpdate::DeadWorkers(workers) => {
self.state.dead_workers = workers;
self.state.set_loading(DataSource::DeadWorkers, false);
}
DataUpdate::TaskDetailLoaded(task) => {
self.task_detail_fetch_inflight = false;
let cached_lines = {
let panel = TaskDetailPanel::new(&task, 0, None);
panel.build_display_lines(&self.theme)
};
self.state.task_detail = Some(task);
self.state.task_detail_cached_lines = cached_lines;
self.state.set_loading(DataSource::TaskDetailData, false);
if self.state.search.active && self.state.show_task_detail {
self.perform_search();
}
}
DataUpdate::TaskListLoaded(rows) => {
self.state.set_task_list_rows(rows);
self.state.set_loading(DataSource::TaskListData, false);
}
DataUpdate::DistinctFilterValues(names, queues, errors) => {
self.state.set_distinct_values(names, queues, errors);
}
DataUpdate::WorkflowSummary(summary) => {
self.state.workflow_summary = Some(summary);
self.state.set_loading(DataSource::WorkflowSummary, false);
}
DataUpdate::WorkflowList(rows) => {
self.state.set_workflow_list(rows);
self.state.set_loading(DataSource::WorkflowList, false);
}
DataUpdate::WorkflowDetailLoaded(workflow, tasks) => {
self.workflow_detail_fetch_inflight = false;
let cached_lines = {
let panel = WorkflowDetailPanel::new(&workflow, &tasks, 0, None);
panel.build_display_lines(&self.theme)
};
self.state.workflow_detail = Some(workflow);
self.state.workflow_tasks = tasks;
self.state.workflow_detail_cached_lines = cached_lines;
self.state.set_loading(DataSource::WorkflowDetail, false);
if self.state.search.active && self.state.show_workflow_detail {
self.perform_search();
}
}
}
}
fn rebuild_detail_caches(&mut self) {
self.state.task_detail_cached_lines = if let Some(task) = &self.state.task_detail {
let panel = TaskDetailPanel::new(task, 0, None);
panel.build_display_lines(&self.theme)
} else {
Vec::new()
};
self.state.workflow_detail_cached_lines = if let Some(workflow) = &self.state.workflow_detail {
let panel = WorkflowDetailPanel::new(workflow, &self.state.workflow_tasks, 0, None);
panel.build_display_lines(&self.theme)
} else {
Vec::new()
};
}
fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> {
tui.resize(Rect::new(0, 0, w, h))?;
self.render(tui)?;
Ok(())
}
fn render(&mut self, tui: &mut Tui) -> Result<()> {
let task_eff_scroll = Cell::new(self.state.task_detail_scroll);
let wf_eff_scroll = Cell::new(self.state.workflow_detail_scroll);
let task_scrollbar_area: Cell<Option<Rect>> = Cell::new(None);
let task_content_height: Cell<u16> = Cell::new(0);
let task_visible_height: Cell<u16> = Cell::new(0);
let wf_scrollbar_area: Cell<Option<Rect>> = Cell::new(None);
let wf_content_height: Cell<u16> = Cell::new(0);
let wf_visible_height: Cell<u16> = Cell::new(0);
let search_eff_scroll = Cell::new(self.state.search.scroll_offset);
let search_view_height = Cell::new(self.state.search.results_view_height);
let search_results_area: Cell<Option<Rect>> = Cell::new(None);
let search_scrollbar_area: Cell<Option<Rect>> = Cell::new(None);
let state = &self.state;
let theme = &self.theme;
let action_tx = self.action_tx.clone();
let current_tab = state.current_tab.clone();
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::BeginSynchronizedUpdate
)
.ok();
let draw_result = tui.draw(|frame| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(1), ])
.split(frame.area());
let render_result = match current_tab {
Tab::Dashboard => {
let mut dashboard = Dashboard::new(state);
dashboard.draw(frame, chunks[0], theme)
}
Tab::Workers => {
let mut workers = Workers::new(state);
workers.draw(frame, chunks[0], theme)
}
Tab::Tasks => {
if state.task_list_active {
let mut task_list = TaskList::new(state);
task_list.draw(frame, chunks[0], theme)
} else {
let mut tasks = Tasks::new(state);
tasks.draw(frame, chunks[0], theme)
}
}
Tab::Maintenance => {
let mut maintenance = Maintenance::new(state);
maintenance.draw(frame, chunks[0], theme)
}
Tab::Workflows => {
let mut workflows = Workflows::new(state);
workflows.draw(frame, chunks[0], theme)
}
};
if let Err(err) = render_result {
let _ = action_tx.send(Action::Error(format!("Failed to draw tab: {:?}", err)));
}
let mut status_bar = StatusBar::new(state);
if let Err(err) = status_bar.draw(frame, chunks[1], theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw status bar: {:?}", err)));
}
if state.show_task_detail {
if let Some(task) = &state.task_detail {
let cached = if state.task_detail_cached_lines.is_empty() {
None
} else {
Some(state.task_detail_cached_lines.as_slice())
};
let mut panel = TaskDetailPanel::with_cached_lines(
task,
state.task_detail_scroll,
state.search_highlight.as_ref(),
cached,
);
if let Err(err) = panel.draw(frame, frame.area(), theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw task detail: {:?}", err)));
}
task_eff_scroll.set(panel.effective_scroll());
task_scrollbar_area.set(panel.scrollbar_area());
task_content_height.set(panel.content_height());
task_visible_height.set(panel.visible_height());
} else {
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
let popup_area = centered_rect(40, 10, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Loading Task... ")
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(theme.accent))
.style(ratatui::style::Style::default().bg(theme.background));
let loading = Paragraph::new("Fetching task details...")
.alignment(ratatui::layout::Alignment::Center)
.block(block);
frame.render_widget(loading, popup_area);
}
}
if state.show_workflow_detail {
if let Some(workflow) = &state.workflow_detail {
let cached = if state.workflow_detail_cached_lines.is_empty() {
None
} else {
Some(state.workflow_detail_cached_lines.as_slice())
};
let mut panel = WorkflowDetailPanel::with_cached_lines(
workflow,
&state.workflow_tasks,
state.workflow_detail_scroll,
state.search_highlight.as_ref(),
cached,
);
if let Err(err) = panel.draw(frame, frame.area(), theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw workflow detail: {:?}", err)));
}
wf_eff_scroll.set(panel.effective_scroll());
wf_scrollbar_area.set(panel.scrollbar_area());
wf_content_height.set(panel.content_height());
wf_visible_height.set(panel.visible_height());
} else {
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
let popup_area = centered_rect(40, 10, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Loading Workflow... ")
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(theme.accent))
.style(ratatui::style::Style::default().bg(theme.background));
let loading = Paragraph::new("Fetching workflow details...")
.alignment(ratatui::layout::Alignment::Center)
.block(block);
frame.render_widget(loading, popup_area);
}
}
if state.show_error_modal && !state.errors.is_empty() {
let mut error_modal = ErrorModal::new(&state.errors);
if let Err(err) = error_modal.draw(frame, frame.area(), theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw error modal: {:?}", err)));
}
}
if state.search.active {
let context_label = if state.show_task_detail {
"Task Detail"
} else if state.show_workflow_detail {
"Workflow Detail"
} else {
match state.current_tab {
Tab::Dashboard => "Dashboard",
Tab::Workers => "Workers",
Tab::Tasks => "Tasks",
Tab::Workflows => "Workflows",
Tab::Maintenance => "Maintenance",
}
};
let mut search_modal = SearchModal::new(&state.search, context_label);
if let Err(err) = search_modal.draw(frame, frame.area(), theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw search modal: {:?}", err)));
}
search_eff_scroll.set(search_modal.effective_scroll());
search_view_height.set(search_modal.results_view_height());
search_results_area.set(search_modal.results_area());
search_scrollbar_area.set(search_modal.scrollbar_area());
}
if let Some(toast) = &state.toast {
render_toast(frame, toast, theme);
}
if state.show_help {
let mut help = HelpOverlay::new();
if let Err(err) = help.draw(frame, frame.area(), theme) {
let _ = action_tx.send(Action::Error(format!("Failed to draw help: {:?}", err)));
}
}
});
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EndSynchronizedUpdate
)
.ok();
draw_result?;
self.state.task_detail_scroll = task_eff_scroll.get();
self.state.workflow_detail_scroll = wf_eff_scroll.get();
self.state.task_detail_scrollbar_area = task_scrollbar_area.get();
self.state.task_detail_content_height = task_content_height.get();
self.state.task_detail_visible_height = task_visible_height.get();
self.state.workflow_detail_scrollbar_area = wf_scrollbar_area.get();
self.state.workflow_detail_content_height = wf_content_height.get();
self.state.workflow_detail_visible_height = wf_visible_height.get();
self.state.search.scroll_offset = search_eff_scroll.get();
self.state.search.results_view_height = search_view_height.get();
self.state.search.results_area = search_results_area.get();
self.state.search.scrollbar_area = search_scrollbar_area.get();
Ok(())
}
}