All checks were successful
BotServer CI / build (push) Successful in 7m30s
503 lines
22 KiB
Rust
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('<', "<").replace('>', ">");
|
|
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 §ion.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 §ion.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 §ion.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
|
|
}
|