generalbots/src/tasks/task_api/html_renderers.rs
Rodrigo Rodriguez (Pragmatismo) 1856215d05
All checks were successful
BotServer CI / build (push) Successful in 7m30s
chore: update dependencies and formatting
2026-02-22 15:55:39 -03:00

503 lines
22 KiB
Rust

//! HTML rendering functions for task UI
use crate::auto_task::TaskManifest;
use crate::core::shared::state::AppState;
use std::sync::Arc;
/// Build HTML for the progress log section from step_results JSON
pub fn build_terminal_html(step_results: &Option<serde_json::Value>, status: &str) -> String {
let mut html = String::new();
let mut output_lines: Vec<(String, bool)> = Vec::new();
if let Some(serde_json::Value::Array(steps)) = step_results {
for step in steps.iter() {
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("");
let is_current = step_status == "running" || step_status == "Running";
if let Some(serde_json::Value::Array(logs)) = step.get("logs") {
for log_entry in logs.iter() {
if let Some(msg) = log_entry.get("message").and_then(|v| v.as_str()) {
if !msg.trim().is_empty() {
output_lines.push((msg.to_string(), is_current));
}
}
if let Some(code) = log_entry.get("code").and_then(|v| v.as_str()) {
if !code.trim().is_empty() {
for line in code.lines().take(20) {
output_lines.push((format!(" {}", line), is_current));
}
}
}
if let Some(output) = log_entry.get("output").and_then(|v| v.as_str()) {
if !output.trim().is_empty() {
for line in output.lines().take(10) {
output_lines.push((format!("{}", line), is_current));
}
}
}
}
}
}
}
if output_lines.is_empty() {
let msg = match status {
"running" => "Agent working...",
"pending" => "Waiting to start...",
"completed" | "done" => "✓ Task completed",
"failed" | "error" => "✗ Task failed",
"paused" => "Task paused",
_ => "Initializing..."
};
html.push_str(&format!(r#"<div class="terminal-line">{}</div>"#, msg));
} else {
let start = if output_lines.len() > 15 { output_lines.len() - 15 } else { 0 };
for (line, is_current) in output_lines[start..].iter() {
let class = if *is_current { "terminal-line current" } else { "terminal-line" };
let escaped = line.replace('<', "&lt;").replace('>', "&gt;");
html.push_str(&format!(r#"<div class="{}">{}</div>"#, class, escaped));
}
}
html
}
pub fn build_taskmd_html(state: &Arc<AppState>, task_id: &str, title: &str, runtime: &str, db_manifest: Option<&serde_json::Value>) -> (String, String) {
log::info!("Building TASK.md view for task_id: {}", task_id);
// First, try to get manifest from in-memory cache (for active/running tasks)
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
log::info!("Found manifest in memory for task: {} with {} sections", manifest.app_name, manifest.sections.len());
let status_html = build_status_section_html(manifest, title, runtime);
let progress_html = build_progress_log_html(manifest);
return (status_html, progress_html);
}
}
// If not in memory, try to load from database (for completed/historical tasks)
if let Some(manifest_json) = db_manifest {
log::info!("Found manifest in database for task: {}", task_id);
if let Ok(manifest) = serde_json::from_value::<TaskManifest>(manifest_json.clone()) {
log::info!("Parsed DB manifest for task: {} with {} sections", manifest.app_name, manifest.sections.len());
let status_html = build_status_section_html(&manifest, title, runtime);
let progress_html = build_progress_log_html(&manifest);
return (status_html, progress_html);
} else {
// Try parsing as web JSON format (the format we store)
if let Some(web_manifest) = super::utils::parse_web_manifest_json(manifest_json) {
log::info!("Parsed web manifest from DB for task: {}", task_id);
let status_html = build_status_section_from_web_json(&web_manifest, title, runtime);
let progress_html = build_progress_log_from_web_json(&web_manifest);
return (status_html, progress_html);
}
log::warn!("Failed to parse manifest JSON for task: {}", task_id);
}
}
log::info!("No manifest found for task: {}", task_id);
let default_status = format!(r#"
<div class="status-row">
<span class="status-title">{}</span>
<span class="status-time">Runtime: {}</span>
</div>
"#, title, runtime);
(default_status, r#"<div class="progress-empty">No steps executed yet</div>"#.to_string())
}
fn build_status_section_from_web_json(manifest: &serde_json::Value, title: &str, runtime: &str) -> String {
let mut html = String::new();
let current_action = manifest
.get("current_status")
.and_then(|s| s.get("current_action"))
.and_then(|a| a.as_str())
.unwrap_or("Processing...");
let estimated_seconds = manifest
.get("estimated_seconds")
.and_then(|e| e.as_u64())
.unwrap_or(0);
let estimated = if estimated_seconds >= 60 {
format!("{} min", estimated_seconds / 60)
} else {
format!("{} sec", estimated_seconds)
};
let runtime_display = if runtime == "0s" || runtime == "calculating..." {
"Not started".to_string()
} else {
runtime.to_string()
};
html.push_str(&format!(r#"
<div class="status-row status-main">
<span class="status-title">{}</span>
<span class="status-time">Runtime: {} <span class="status-indicator"></span></span>
</div>
<div class="status-row status-current">
<span class="status-dot active"></span>
<span class="status-text">{}</span>
<span class="status-time">Estimated: {} <span class="status-gear">⚙</span></span>
</div>
"#, title, runtime_display, current_action, estimated));
html
}
fn build_progress_log_from_web_json(manifest: &serde_json::Value) -> String {
let mut html = String::new();
html.push_str(r#"<div class="taskmd-tree">"#);
let total_steps = manifest
.get("total_steps")
.and_then(|t| t.as_u64())
.unwrap_or(60) as u32;
let sections = match manifest.get("sections").and_then(|s| s.as_array()) {
Some(s) => s,
None => {
html.push_str("</div>");
return html;
}
};
for section in sections {
let section_id = section.get("id").and_then(|i| i.as_str()).unwrap_or("unknown");
let section_name = section.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown");
let section_status = section.get("status").and_then(|s| s.as_str()).unwrap_or("Pending");
// Progress fields are nested inside a "progress" object in the web JSON format
let progress = section.get("progress");
let current_step = progress
.and_then(|p| p.get("current"))
.and_then(|c| c.as_u64())
.unwrap_or(0) as u32;
let global_step_start = progress
.and_then(|p| p.get("global_start"))
.and_then(|g| g.as_u64())
.unwrap_or(0) as u32;
let section_class = match section_status.to_lowercase().as_str() {
"completed" => "completed expanded",
"running" => "running expanded",
"failed" => "failed",
"skipped" => "skipped",
_ => "pending",
};
let global_current = global_step_start + current_step;
html.push_str(&format!(r#"
<div class="tree-section {} expanded" data-section-id="{}">
<div class="tree-row tree-level-0" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
<span class="tree-section-dot {}"></span>
</div>
<div class="tree-children">
"#, section_class, section_id, section_name, global_current, total_steps, section_class, section_status, section_class));
// Render children
if let Some(children) = section.get("children").and_then(|c| c.as_array()) {
for child in children {
let child_id = child.get("id").and_then(|i| i.as_str()).unwrap_or("unknown");
let child_name = child.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown");
let child_status = child.get("status").and_then(|s| s.as_str()).unwrap_or("Pending");
// Progress fields are nested inside a "progress" object in the web JSON format
let child_progress = child.get("progress");
let child_current = child_progress
.and_then(|p| p.get("current"))
.and_then(|c| c.as_u64())
.unwrap_or(0) as u32;
let child_total = child_progress
.and_then(|p| p.get("total"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
let child_class = match child_status.to_lowercase().as_str() {
"completed" => "completed expanded",
"running" => "running expanded",
"failed" => "failed",
"skipped" => "skipped",
_ => "pending",
};
html.push_str(&format!(r#"
<div class="tree-child {} expanded" data-child-id="{}">
<div class="tree-row tree-level-1" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-indent"></span>
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
</div>
<div class="tree-items">
"#, child_class, child_id, child_name, child_current, child_total, child_class, child_status));
// Render items
if let Some(items) = child.get("items").and_then(|i| i.as_array()) {
for item in items {
let item_name = item.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown");
let item_status = item.get("status").and_then(|s| s.as_str()).unwrap_or("Pending");
let duration = item.get("duration_seconds").and_then(|d| d.as_u64());
let item_class = match item_status.to_lowercase().as_str() {
"completed" => "completed",
"running" => "running",
_ => "pending",
};
let check_mark = if item_status.to_lowercase() == "completed" { "" } else { "" };
let duration_str = duration
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
html.push_str(&format!(r#"
<div class="tree-item {}">
<span class="item-dot {}"></span>
<span class="item-name">{}</span>
<div class="item-info">
<span class="item-duration">{}</span>
<span class="item-check {}">{}</span>
</div>
</div>
"#, item_class, item_class, item_name, duration_str, item_class, check_mark));
}
}
html.push_str("</div></div>"); // Close tree-items and tree-child
}
}
html.push_str("</div></div>"); // Close tree-children and tree-section
}
html.push_str("</div>"); // Close taskmd-tree
html
}
fn build_status_section_html(manifest: &TaskManifest, _title: &str, runtime: &str) -> String {
let mut html = String::new();
let current_action = manifest.current_status.current_action.as_deref().unwrap_or("Processing...");
// Format estimated time nicely
let estimated = if manifest.estimated_seconds >= 60 {
format!("{} min", manifest.estimated_seconds / 60)
} else {
format!("{} sec", manifest.estimated_seconds)
};
// Format runtime nicely
let runtime_display = if runtime == "0s" || runtime == "calculating..." {
"Not started".to_string()
} else {
runtime.to_string()
};
html.push_str(&format!(r#"
<div class="status-row status-current">
<span class="status-dot active"></span>
<span class="status-text">{}</span>
<span class="status-time">Runtime: {} | Est: {}</span>
</div>
"#, current_action, runtime_display, estimated));
if let Some(ref dp) = manifest.current_status.decision_point {
html.push_str(&format!(r#"
<div class="status-row status-decision">
<span class="status-dot pending"></span>
<span class="status-text">Decision Point Coming (Step {}/{})</span>
<span class="status-badge">{}</span>
</div>
"#, dp.step_current, dp.step_total, dp.message));
}
html
}
fn build_progress_log_html(manifest: &TaskManifest) -> String {
let mut html = String::new();
html.push_str(r#"<div class="taskmd-tree">"#);
let total_steps = manifest.total_steps;
log::info!("Building progress log, {} sections, total_steps={}", manifest.sections.len(), total_steps);
for section in &manifest.sections {
log::info!("Section '{}': children={}, items={}, item_groups={}",
section.name, section.children.len(), section.items.len(), section.item_groups.len());
let section_class = match section.status {
crate::auto_task::SectionStatus::Completed => "completed expanded",
crate::auto_task::SectionStatus::Running => "running expanded",
crate::auto_task::SectionStatus::Failed => "failed",
crate::auto_task::SectionStatus::Skipped => "skipped",
_ => "pending",
};
let status_text = match section.status {
crate::auto_task::SectionStatus::Completed => "Completed",
crate::auto_task::SectionStatus::Running => "Running",
crate::auto_task::SectionStatus::Failed => "Failed",
crate::auto_task::SectionStatus::Skipped => "Skipped",
_ => "Pending",
};
// Use global step count (e.g., "Step 24/60")
let global_current = section.global_step_start + section.current_step;
html.push_str(&format!(r#"
<div class="tree-section {} expanded" data-section-id="{}">
<div class="tree-row tree-level-0" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
<span class="tree-section-dot {}"></span>
</div>
<div class="tree-children">
"#, section_class, section.id, section.name, global_current, total_steps, section_class, status_text, section_class));
for child in &section.children {
log::info!(" Child '{}': items={}, item_groups={}",
child.name, child.items.len(), child.item_groups.len());
let child_class = match child.status {
crate::auto_task::SectionStatus::Completed => "completed expanded",
crate::auto_task::SectionStatus::Running => "running expanded",
crate::auto_task::SectionStatus::Failed => "failed",
crate::auto_task::SectionStatus::Skipped => "skipped",
_ => "pending",
};
let child_status = match child.status {
crate::auto_task::SectionStatus::Completed => "Completed",
crate::auto_task::SectionStatus::Running => "Running",
crate::auto_task::SectionStatus::Failed => "Failed",
crate::auto_task::SectionStatus::Skipped => "Skipped",
_ => "Pending",
};
html.push_str(&format!(r#"
<div class="tree-child {} expanded" data-child-id="{}" onclick="this.classList.toggle('expanded')">
<div class="tree-row tree-level-1">
<span class="tree-indent"></span>
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
</div>
<div class="tree-items">
"#, child_class, child.id, child.name, child.current_step, child.total_steps, child_class, child_status));
// Render item groups first (grouped fields like "email, password_hash, email_verified")
for group in &child.item_groups {
let group_class = match group.status {
crate::auto_task::ItemStatus::Completed => "completed",
crate::auto_task::ItemStatus::Running => "running",
_ => "pending",
};
let check_mark = if group.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let group_duration = group.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
let group_name = group.display_name();
html.push_str(&format!(r#"
<div class="tree-item {}" data-item-id="{}">
<span class="tree-item-dot {}"></span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, group_class, group.id, group_class, group_name, group_duration, group_class, check_mark));
}
// Then individual items
for item in &child.items {
let item_class = match item.status {
crate::auto_task::ItemStatus::Completed => "completed",
crate::auto_task::ItemStatus::Running => "running",
_ => "pending",
};
let check_mark = if item.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let item_duration = item.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
html.push_str(&format!(r#"
<div class="tree-item {}" data-item-id="{}">
<span class="tree-item-dot {}"></span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, item_class, item.id, item_class, item.name, item_duration, item_class, check_mark));
}
html.push_str("</div></div>");
}
// Render section-level item groups
for group in &section.item_groups {
let group_class = match group.status {
crate::auto_task::ItemStatus::Completed => "completed",
crate::auto_task::ItemStatus::Running => "running",
_ => "pending",
};
let check_mark = if group.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let group_duration = group.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
let group_name = group.display_name();
html.push_str(&format!(r#"
<div class="tree-item {}" data-item-id="{}">
<span class="tree-item-dot {}"></span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, group_class, group.id, group_class, group_name, group_duration, group_class, check_mark));
}
// Render section-level items
for item in &section.items {
let item_class = match item.status {
crate::auto_task::ItemStatus::Completed => "completed",
crate::auto_task::ItemStatus::Running => "running",
_ => "pending",
};
let check_mark = if item.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let item_duration = item.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
html.push_str(&format!(r#"
<div class="tree-item {}" data-item-id="{}">
<span class="tree-item-dot {}"></span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, item_class, item.id, item_class, item.name, item_duration, item_class, check_mark));
}
html.push_str("</div></div>");
}
html.push_str("</div>");
if manifest.sections.is_empty() {
return r#"<div class="progress-empty">No steps executed yet</div>"#.to_string();
}
html
}