use std::borrow::Cow;
use crate::{action::Action, components::Component, errors::Result, models::TaskDetail, state::SearchHighlight, theme::Theme};
use ratatui::{
prelude::*,
widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
pub struct TaskDetailPanel<'a> {
task: &'a TaskDetail,
scroll_offset: u16,
highlight: Option<&'a SearchHighlight>,
cached_lines: Option<&'a [Line<'static>]>,
effective_scroll: u16,
scrollbar_area: Option<Rect>,
content_height: u16,
visible_height: u16,
}
pub struct SearchableLine {
pub display: String,
pub search_text: String,
}
struct DetailLine {
display: Line<'static>,
display_text: String,
search_text: String,
}
impl DetailLine {
fn new(display: Line<'static>) -> Self {
let display_text = line_to_string(&display);
Self {
display,
search_text: display_text.clone(),
display_text,
}
}
}
fn line_to_string(line: &Line) -> String {
let mut text = String::new();
for span in &line.spans {
text.push_str(span.content.as_ref());
}
text
}
impl<'a> TaskDetailPanel<'a> {
pub fn new(task: &'a TaskDetail, scroll_offset: u16, highlight: Option<&'a SearchHighlight>) -> Self {
Self::with_cached_lines(task, scroll_offset, highlight, None)
}
pub fn with_cached_lines(
task: &'a TaskDetail,
scroll_offset: u16,
highlight: Option<&'a SearchHighlight>,
cached_lines: Option<&'a [Line<'static>]>,
) -> Self {
Self {
task,
scroll_offset,
highlight,
cached_lines,
effective_scroll: 0,
scrollbar_area: None,
content_height: 0,
visible_height: 0,
}
}
pub fn effective_scroll(&self) -> u16 {
self.effective_scroll
}
pub fn scrollbar_area(&self) -> Option<Rect> {
self.scrollbar_area
}
pub fn content_height(&self) -> u16 {
self.content_height
}
pub fn visible_height(&self) -> u16 {
self.visible_height
}
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 format_timestamp(dt: &Option<chrono::DateTime<chrono::Utc>>) -> Cow<'static, str> {
match dt {
Some(t) => Cow::Owned(t.format("%Y-%m-%d %H:%M:%S UTC").to_string()),
None => Cow::Borrowed("-"),
}
}
fn status_color(status: &str, theme: &Theme) -> Color {
if status.eq_ignore_ascii_case("PENDING") {
Color::Yellow
} else if status.eq_ignore_ascii_case("CLAIMED") {
Color::Cyan
} else if status.eq_ignore_ascii_case("RUNNING") {
Color::Blue
} else if status.eq_ignore_ascii_case("COMPLETED") {
theme.success
} else if status.eq_ignore_ascii_case("FAILED") {
theme.error
} else if status.eq_ignore_ascii_case("CANCELLED") {
theme.muted
} else if status.eq_ignore_ascii_case("EXPIRED") {
Color::DarkGray
} else {
theme.text
}
}
fn format_json(json_str: &Option<String>) -> Vec<String> {
match json_str {
Some(s) if !s.is_empty() => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
let expanded = Self::expand_nested_json(parsed);
if let Ok(pretty) = serde_json::to_string_pretty(&expanded) {
return pretty.lines().map(String::from).collect();
}
}
s.lines().map(String::from).collect()
}
_ => vec!["-".to_string()],
}
}
fn expand_nested_json(value: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match value {
Value::String(s) => {
if s.starts_with('{') || s.starts_with('[') {
if let Ok(inner) = serde_json::from_str::<Value>(&s) {
return Self::expand_nested_json(inner);
}
}
Value::String(s)
}
Value::Array(arr) => {
Value::Array(arr.into_iter().map(Self::expand_nested_json).collect())
}
Value::Object(obj) => {
Value::Object(
obj.into_iter()
.map(|(k, v)| (k, Self::expand_nested_json(v)))
.collect(),
)
}
other => other,
}
}
fn build_detail_lines(&self, theme: &Theme) -> Vec<DetailLine> {
let mut lines: Vec<DetailLine> = Vec::new();
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Task Name: ", Style::default().fg(theme.muted)),
Span::styled(self.task.task_name.clone(), Style::default().fg(theme.accent).bold()),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Queue: ", Style::default().fg(theme.muted)),
Span::styled(self.task.queue_name.clone(), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Status: ", Style::default().fg(theme.muted)),
Span::styled(
self.task.status.clone(),
Style::default().fg(Self::status_color(&self.task.status, theme)).bold(),
),
])));
if let Some(ref code) = self.task.error_code {
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Error Code: ", Style::default().fg(theme.muted)),
Span::styled(code.clone(), Style::default().fg(theme.error).bold()),
])));
}
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Priority: ", Style::default().fg(theme.muted)),
Span::styled(format!("{}", self.task.priority), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Retries: ", Style::default().fg(theme.muted)),
Span::styled(
format!("{} / {}", self.task.retry_count, self.task.max_retries),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from("")));
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Worker Info", Style::default().fg(theme.accent).bold()),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Worker ID: ", Style::default().fg(theme.muted)),
Span::styled(
self.task
.claimed_by_worker_id
.clone()
.unwrap_or_else(|| "-".to_string()),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Hostname: ", Style::default().fg(theme.muted)),
Span::styled(
self.task
.worker_hostname
.clone()
.unwrap_or_else(|| "-".to_string()),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" PID: ", Style::default().fg(theme.muted)),
Span::styled(
self.task.worker_pid.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string()),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Process: ", Style::default().fg(theme.muted)),
Span::styled(
self.task.worker_process_name.clone().unwrap_or_else(|| "-".to_string()),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from("")));
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Timeline", Style::default().fg(theme.accent).bold()),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Created: ", Style::default().fg(theme.muted)),
Span::styled(
self.task.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Sent: ", Style::default().fg(theme.muted)),
Span::styled(Self::format_timestamp(&self.task.sent_at), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Enqueued: ", Style::default().fg(theme.muted)),
Span::styled(self.task.enqueued_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Claimed: ", Style::default().fg(theme.muted)),
Span::styled(Self::format_timestamp(&self.task.claimed_at), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Started: ", Style::default().fg(theme.muted)),
Span::styled(Self::format_timestamp(&self.task.started_at), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Completed: ", Style::default().fg(theme.muted)),
Span::styled(Self::format_timestamp(&self.task.completed_at), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Failed: ", Style::default().fg(theme.muted)),
Span::styled(Self::format_timestamp(&self.task.failed_at), Style::default().fg(theme.text)),
])));
lines.push(DetailLine::new(Line::from("")));
if !self.task.attempts.is_empty() {
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Attempts", Style::default().fg(theme.accent).bold()),
])));
for (i, attempt) in self.task.attempts.iter().enumerate() {
let retry_label = if attempt.will_retry { "yes" } else { "no" };
let outcome_color = match attempt.outcome.as_str() {
"COMPLETED" => theme.success,
"FAILED" => theme.error,
"WORKER_FAILURE" => theme.error,
_ => theme.text,
};
lines.push(DetailLine::new(Line::from(vec![
Span::styled(
format!(" Attempt {} | ", attempt.attempt),
Style::default().fg(theme.muted),
),
Span::styled(
attempt.outcome.clone(),
Style::default().fg(outcome_color).bold(),
),
Span::styled(
format!(" | retry={}", retry_label),
Style::default().fg(theme.muted),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Started: ", Style::default().fg(theme.muted)),
Span::styled(
attempt.started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
Style::default().fg(theme.text),
),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Finished: ", Style::default().fg(theme.muted)),
Span::styled(
attempt.finished_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
Style::default().fg(theme.text),
),
])));
if let Some(ref code) = attempt.error_code {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Error Code: ", Style::default().fg(theme.muted)),
Span::styled(code.clone(), Style::default().fg(theme.error)),
])));
}
if let Some(ref msg) = attempt.error_message {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Error Message: ", Style::default().fg(theme.muted)),
Span::styled(msg.clone(), Style::default().fg(theme.error)),
])));
}
if let Some(ref reason) = attempt.failed_reason {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Failed Reason: ", Style::default().fg(theme.muted)),
Span::styled(reason.clone(), Style::default().fg(theme.error)),
])));
}
let has_worker = attempt.worker_id.is_some()
|| attempt.worker_hostname.is_some()
|| attempt.worker_pid.is_some()
|| attempt.worker_process_name.is_some();
if has_worker {
let parts: Vec<String> = [
attempt.worker_id.clone(),
attempt.worker_hostname.clone(),
attempt.worker_pid.map(|p| p.to_string()),
attempt.worker_process_name.clone(),
]
.into_iter()
.flatten()
.collect();
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Worker: ", Style::default().fg(theme.muted)),
Span::styled(parts.join(" | "), Style::default().fg(theme.text)),
])));
}
if i < self.task.attempts.len() - 1 {
lines.push(DetailLine::new(Line::from("")));
}
}
lines.push(DetailLine::new(Line::from("")));
}
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Arguments", Style::default().fg(theme.accent).bold()),
])));
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" args: ", Style::default().fg(theme.muted)),
])));
for arg_line in Self::format_json(&self.task.args) {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(format!(" {}", arg_line), Style::default().fg(theme.text)),
])));
}
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" kwargs: ", Style::default().fg(theme.muted)),
])));
for kwarg_line in Self::format_json(&self.task.kwargs) {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(format!(" {}", kwarg_line), Style::default().fg(theme.text)),
])));
}
lines.push(DetailLine::new(Line::from("")));
if self.task.status.to_uppercase() == "COMPLETED" {
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Result", Style::default().fg(theme.success).bold()),
])));
for result_line in Self::format_json(&self.task.result) {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(format!(" {}", result_line), Style::default().fg(theme.text)),
])));
}
} else if self.task.status.to_uppercase() == "FAILED" {
lines.push(DetailLine::new(Line::from(vec![
Span::styled("Error", Style::default().fg(theme.error).bold()),
])));
let has_failed_reason = self.task.failed_reason.as_ref().is_some_and(|s| !s.is_empty());
let has_result = self.task.result.as_ref().is_some_and(|s| !s.is_empty());
if has_failed_reason {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Reason:", Style::default().fg(theme.error).bold()),
])));
for error_line in Self::format_json(&self.task.failed_reason) {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(format!(" {}", error_line), Style::default().fg(theme.error)),
])));
}
}
if has_result {
if has_failed_reason {
lines.push(DetailLine::new(Line::from("")));
}
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" Result:", Style::default().fg(theme.error).bold()),
])));
for result_line in Self::format_json(&self.task.result) {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(format!(" {}", result_line), Style::default().fg(theme.error)),
])));
}
}
if !has_failed_reason && !has_result {
lines.push(DetailLine::new(Line::from(vec![
Span::styled(" -", Style::default().fg(theme.error)),
])));
}
}
lines
}
pub fn build_search_lines(&self, theme: &Theme) -> Vec<SearchableLine> {
self.build_detail_lines(theme)
.into_iter()
.map(|line| SearchableLine {
display: line.display_text,
search_text: line.search_text,
})
.collect()
}
pub fn build_display_lines(&self, theme: &Theme) -> Vec<Line<'static>> {
self.build_detail_lines(theme)
.into_iter()
.map(|line| line.display)
.collect()
}
}
impl<'a> Component for TaskDetailPanel<'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 popup_area = Self::centered_rect(80, 85, area);
frame.render_widget(Clear, popup_area);
let title = format!(" Task: {} - Esc: close | ↑↓/PgUp/PgDn/Home/End: scroll | []: prev/next | y: copy ", self.task.id);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.border_type(BorderType::Rounded)
.padding(Padding::uniform(1))
.style(Style::default().bg(theme.surface));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let owned_lines;
let source_lines: &[Line<'static>] = if let Some(cached) = self.cached_lines {
cached
} else {
owned_lines = self.build_display_lines(theme);
&owned_lines
};
let content_height = source_lines.len() as u16;
let visible_height = inner.height;
self.content_height = content_height;
self.visible_height = visible_height;
let scroll = self.scroll_offset.min(content_height.saturating_sub(visible_height));
self.effective_scroll = scroll;
let start = scroll as usize;
let end = (start + visible_height as usize).min(source_lines.len());
let mut visible_lines: Vec<Line> = source_lines[start..end].iter().cloned().collect();
if let Some(highlight) = &self.highlight {
for (offset, line) in visible_lines.iter_mut().enumerate() {
if highlight.matches_line(start + offset) {
line.spans.push(Span::styled(
format!(" {}", highlight.pointer()),
Style::default().fg(theme.accent),
));
}
}
}
let paragraph = Paragraph::new(visible_lines)
.style(Style::default().bg(theme.background).fg(theme.text))
.scroll((0, 0));
frame.render_widget(paragraph, inner);
if content_height > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"))
.track_symbol(Some("│"))
.thumb_symbol("█");
let scrollbar_track = inner.inner(Margin { vertical: 1, horizontal: 0 });
self.scrollbar_area = Some(scrollbar_track);
let mut scrollbar_state = ScrollbarState::new(content_height as usize)
.position(scroll as usize)
.viewport_content_length(visible_height as usize);
frame.render_stateful_widget(
scrollbar,
scrollbar_track,
&mut scrollbar_state,
);
} else {
self.scrollbar_area = None;
}
Ok(())
}
}