Skip to main content

task_graph_mcp/dashboard/
server.rs

1//! HTTP server implementation for the web dashboard.
2//!
3//! This module provides the axum-based HTTP server that serves the dashboard UI
4//! and exposes REST API endpoints.
5
6use axum::{
7    Router,
8    extract::{Form, Path, Query, State},
9    response::{Html, IntoResponse, Json, Redirect},
10    routing::{get, post},
11};
12use std::net::SocketAddr;
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::sync::{oneshot, watch};
16use tower_http::cors::{Any, CorsLayer};
17use tower_http::trace::TraceLayer;
18use tracing::info;
19
20use super::templates;
21use crate::config::{StatesConfig, UiConfig};
22use crate::db::Database;
23use crate::db::dashboard::{ActivityListQuery, TaskListQuery};
24use crate::db::now_ms;
25use tracing::warn;
26
27/// Dashboard server state shared across handlers.
28#[derive(Clone)]
29pub struct DashboardServer {
30    /// Reference to the task database.
31    db: Arc<Database>,
32    /// Port the server is listening on.
33    port: u16,
34    /// States configuration for determining timed/untimed states.
35    states_config: Arc<StatesConfig>,
36}
37
38impl DashboardServer {
39    /// Create a new dashboard server instance.
40    pub fn new(db: Arc<Database>, port: u16, states_config: Arc<StatesConfig>) -> Self {
41        Self {
42            db,
43            port,
44            states_config,
45        }
46    }
47
48    /// Get the database reference.
49    pub fn db(&self) -> &Arc<Database> {
50        &self.db
51    }
52
53    /// Get the configured port.
54    pub fn port(&self) -> u16 {
55        self.port
56    }
57
58    /// Get the states configuration.
59    pub fn states_config(&self) -> &StatesConfig {
60        &self.states_config
61    }
62}
63
64/// Health check response.
65#[derive(serde::Serialize)]
66struct HealthResponse {
67    status: &'static str,
68    version: &'static str,
69}
70
71/// Root endpoint - serves the dashboard index page with htmx.
72async fn root() -> Html<&'static str> {
73    Html(templates::INDEX_TEMPLATE)
74}
75
76/// Workers page - serves the workers list page.
77async fn workers_page() -> Html<&'static str> {
78    Html(templates::WORKERS_TEMPLATE)
79}
80
81/// Stats API endpoint for htmx - returns HTML fragment.
82async fn api_stats(State(state): State<DashboardServer>) -> Html<String> {
83    // Query task counts from database
84    let (total, working, completed): (i64, i64, i64) =
85        state.db().get_task_stats().unwrap_or_default();
86
87    // Query worker count from database
88    let worker_count: i64 = state.db().get_active_worker_count().unwrap_or_default();
89
90    Html(format!(
91        r#"
92        <div class="grid grid-stats">
93            <div class="card stat">
94                <div class="stat-value">{}</div>
95                <div class="stat-label">Total Tasks</div>
96            </div>
97            <div class="card stat">
98                <div class="stat-value">{}</div>
99                <div class="stat-label">Active Workers</div>
100            </div>
101            <div class="card stat">
102                <div class="stat-value">{}</div>
103                <div class="stat-label">In Progress</div>
104            </div>
105            <div class="card stat">
106                <div class="stat-value">{}</div>
107                <div class="stat-label">Completed</div>
108            </div>
109        </div>
110    "#,
111        total, worker_count, working, completed
112    ))
113}
114
115/// Recent tasks API endpoint for htmx - returns HTML fragment.
116async fn api_recent_tasks(State(state): State<DashboardServer>) -> Html<String> {
117    let tasks = state.db().get_recent_tasks(5).unwrap_or_default();
118
119    if tasks.is_empty() {
120        return Html(r#"<div class="empty-state">No tasks found</div>"#.to_string());
121    }
122
123    let mut html = String::from(
124        "<table><thead><tr><th>Task</th><th>Status</th><th>Priority</th></tr></thead><tbody>",
125    );
126
127    for task in tasks {
128        let badge_class = match task.status.as_str() {
129            "completed" => "badge-success",
130            "working" => "badge-info",
131            "failed" => "badge-error",
132            "pending" => "badge-pending",
133            _ => "badge-warning",
134        };
135
136        let title = task
137            .title
138            .as_deref()
139            .filter(|t| !t.is_empty())
140            .unwrap_or(&task.id);
141
142        html.push_str(&format!(
143            r#"<tr><td>{}</td><td><span class="badge {}">{}</span></td><td>{}</td></tr>"#,
144            html_escape(title),
145            badge_class,
146            task.status,
147            task.priority
148        ));
149    }
150
151    html.push_str("</tbody></table>");
152    Html(html)
153}
154
155/// Active workers API endpoint for htmx - returns HTML fragment.
156async fn api_active_workers(State(state): State<DashboardServer>) -> Html<String> {
157    let workers = state.db().get_active_workers().unwrap_or_default();
158
159    if workers.is_empty() {
160        return Html(r#"<div class="empty-state">No active workers</div>"#.to_string());
161    }
162
163    let mut html = String::from(
164        "<table><thead><tr><th>Worker</th><th>Status</th><th>Claims</th></tr></thead><tbody>",
165    );
166
167    for worker in workers {
168        html.push_str(&format!(
169            r#"<tr><td><div class="worker-status"><span class="status-dot online"></span>{}</div></td><td>{}</td><td>{}</td></tr>"#,
170            html_escape(&worker.id),
171            worker.current_thought.as_deref().unwrap_or("idle"),
172            worker.claim_count
173        ));
174    }
175
176    html.push_str("</tbody></table>");
177    Html(html)
178}
179
180/// Format milliseconds as human-readable time ago string.
181fn format_time_ago(ms_ago: i64) -> (String, &'static str) {
182    let seconds = ms_ago / 1000;
183    let (text, class) = if seconds < 60 {
184        (format!("{}s ago", seconds), "recent")
185    } else if seconds < 3600 {
186        (format!("{}m ago", seconds / 60), "recent")
187    } else if seconds < 86400 {
188        let hours = seconds / 3600;
189        (
190            format!("{}h ago", hours),
191            if hours < 2 { "recent" } else { "stale" },
192        )
193    } else {
194        (format!("{}d ago", seconds / 86400), "old")
195    };
196    (text, class)
197}
198
199/// Workers list API endpoint for htmx - returns HTML fragment with full worker details.
200async fn api_workers_list(State(state): State<DashboardServer>) -> Html<String> {
201    let workers = state
202        .db()
203        .list_workers_info(&state.states_config)
204        .unwrap_or_default();
205
206    if workers.is_empty() {
207        return Html(r#"<div class="empty-state">No workers registered</div>"#.to_string());
208    }
209
210    let now = now_ms();
211    let mut html = String::from(
212        r#"<table>
213        <thead>
214            <tr>
215                <th></th>
216                <th>Worker ID</th>
217                <th>Tags</th>
218                <th>Registered</th>
219                <th>Last Heartbeat</th>
220                <th>Claims</th>
221            </tr>
222        </thead>
223        <tbody>"#,
224    );
225
226    for worker in &workers {
227        // Determine worker status based on heartbeat
228        let heartbeat_age = now - worker.last_heartbeat;
229        let status_class = if heartbeat_age < 60_000 {
230            "online"
231        } else if heartbeat_age < 300_000 {
232            "stale"
233        } else {
234            "offline"
235        };
236
237        // Format heartbeat time
238        let (heartbeat_text, heartbeat_class) = format_time_ago(heartbeat_age);
239
240        // Format registered time
241        let registered_age = now - worker.registered_at;
242        let (registered_text, _) = format_time_ago(registered_age);
243
244        // Format tags
245        let tags_html: String = worker
246            .tags
247            .iter()
248            .map(|t| format!(r#"<span class="tag">{}</span>"#, html_escape(t)))
249            .collect();
250
251        // Escape worker ID for use in HTML attribute
252        let worker_id_escaped = html_escape(&worker.id);
253        let worker_id_attr = worker.id.replace('"', "&quot;").replace('\'', "&#39;");
254
255        html.push_str(&format!(
256            r#"<tr class="expandable-row" onclick="toggleWorkerDetail('{worker_id_attr}')">
257                <td><span id="expand-icon-{worker_id_attr}" class="expand-icon">&#9654;</span></td>
258                <td><div class="worker-status"><span class="status-dot {status_class}"></span>{worker_id_escaped}</div></td>
259                <td>{tags_html}</td>
260                <td><span class="time-ago">{registered_text}</span></td>
261                <td><span class="time-ago {heartbeat_class}">{heartbeat_text}</span></td>
262                <td>{claim_count}</td>
263            </tr>
264            <tr id="worker-detail-{worker_id_attr}" class="detail-row">
265                <td colspan="6">
266                    <div class="detail-content"
267                         hx-get="/api/workers/{worker_id_attr}/details"
268                         hx-trigger="load-details"
269                         hx-swap="innerHTML">
270                        <div class="empty-state">Loading details...</div>
271                    </div>
272                </td>
273            </tr>"#,
274            worker_id_attr = worker_id_attr,
275            worker_id_escaped = worker_id_escaped,
276            status_class = status_class,
277            tags_html = if tags_html.is_empty() { "<span class=\"tag\">none</span>".to_string() } else { tags_html },
278            registered_text = registered_text,
279            heartbeat_text = heartbeat_text,
280            heartbeat_class = heartbeat_class,
281            claim_count = worker.claim_count,
282        ));
283    }
284
285    html.push_str("</tbody></table>");
286    Html(html)
287}
288
289/// Worker details API endpoint for htmx - returns HTML fragment with claims and file marks.
290async fn api_worker_details(
291    State(state): State<DashboardServer>,
292    axum::extract::Path(worker_id): axum::extract::Path<String>,
293) -> Html<String> {
294    let mut html = String::new();
295
296    // Get claimed tasks
297    let tasks = state
298        .db()
299        .get_worker_claimed_tasks(&worker_id)
300        .unwrap_or_default();
301
302    html.push_str(r#"<div class="detail-section"><h3>Claimed Tasks</h3>"#);
303    if tasks.is_empty() {
304        html.push_str(r#"<div class="empty-state">No claimed tasks</div>"#);
305    } else {
306        html.push_str(r#"<ul class="detail-list">"#);
307        for task in tasks {
308            let title = task
309                .title
310                .as_deref()
311                .filter(|t| !t.is_empty())
312                .unwrap_or(&task.id);
313            let thought = task
314                .current_thought
315                .as_deref()
316                .map(|t| format!(r#" - <em>{}</em>"#, html_escape(t)))
317                .unwrap_or_default();
318            html.push_str(&format!(
319                r#"<li><strong>{}</strong>{}</li>"#,
320                html_escape(title),
321                thought
322            ));
323        }
324        html.push_str("</ul>");
325    }
326    html.push_str("</div>");
327
328    // Get file marks
329    let file_locks = state
330        .db()
331        .get_file_locks(None, Some(&worker_id), None)
332        .unwrap_or_default();
333
334    html.push_str(r#"<div class="detail-section"><h3>File Marks</h3>"#);
335    if file_locks.is_empty() {
336        html.push_str(r#"<div class="empty-state">No file marks</div>"#);
337    } else {
338        html.push_str(r#"<ul class="detail-list">"#);
339        for (file_path, lock) in file_locks {
340            let reason = lock
341                .reason
342                .as_deref()
343                .map(|r| format!(r#" - {}"#, html_escape(r)))
344                .unwrap_or_default();
345            html.push_str(&format!(
346                r#"<li><span class="file-path">{}</span>{}</li>"#,
347                html_escape(&file_path),
348                reason
349            ));
350        }
351        html.push_str("</ul>");
352    }
353    html.push_str("</div>");
354
355    // Add disconnect button
356    let worker_id_attr = worker_id.replace('"', "&quot;").replace('\'', "&#39;");
357    html.push_str(&format!(
358        "<div class=\"detail-actions\">\
359            <form hx-post=\"/api/workers/{worker_id}/disconnect\"\
360                  hx-target=\"#workers-list\"\
361                  hx-swap=\"innerHTML\"\
362                  hx-confirm=\"Are you sure you want to disconnect this worker? Their claimed tasks will be released.\">\
363                <label>Release tasks as:\
364                    <select name=\"final_status\" class=\"select\">\
365                        <option value=\"pending\" selected>Pending</option>\
366                        <option value=\"failed\">Failed</option>\
367                        <option value=\"cancelled\">Cancelled</option>\
368                    </select>\
369                </label>\
370                <button type=\"submit\" class=\"btn btn-danger btn-sm\">Disconnect Worker</button>\
371            </form>\
372        </div>",
373        worker_id = worker_id_attr,
374    ));
375
376    Html(html)
377}
378
379/// Form data for disconnect endpoint.
380#[derive(Debug, serde::Deserialize)]
381struct DisconnectForm {
382    final_status: Option<String>,
383}
384
385/// Disconnect a worker - releases all claims and removes worker.
386async fn api_worker_disconnect(
387    State(state): State<DashboardServer>,
388    Path(worker_id): Path<String>,
389    Form(form): Form<DisconnectForm>,
390) -> Html<String> {
391    let final_status = form.final_status.as_deref().unwrap_or("pending");
392
393    match state.db().unregister_worker(&worker_id, final_status) {
394        Ok(summary) => {
395            // Return updated workers list
396            let workers = state
397                .db()
398                .list_workers_info(&state.states_config)
399                .unwrap_or_default();
400            if workers.is_empty() {
401                return Html(format!(
402                    r#"<div class="empty-state">Worker '{}' disconnected. {} tasks released as {}. No workers remaining.</div>"#,
403                    html_escape(&worker_id),
404                    summary.tasks_released,
405                    final_status
406                ));
407            }
408
409            // Re-render the workers list (simplified - redirect to refresh)
410            api_workers_list(State(state)).await
411        }
412        Err(e) => Html(format!(
413            r#"<div class="empty-state" style="color: var(--accent);">Error disconnecting worker: {}</div>"#,
414            html_escape(&e.to_string())
415        )),
416    }
417}
418
419/// Cleanup stale workers endpoint.
420async fn api_workers_cleanup(State(state): State<DashboardServer>) -> Html<String> {
421    // Default timeout: 5 minutes (300 seconds)
422    let timeout_seconds = 300;
423    let final_status = "pending";
424
425    match state
426        .db()
427        .cleanup_stale_workers(timeout_seconds, final_status)
428    {
429        Ok(summary) => {
430            if summary.workers_evicted == 0 {
431                Html(r#"<span class="badge badge-info">No stale workers</span>"#.to_string())
432            } else {
433                Html(format!(
434                    r#"<span class="badge badge-success">{} evicted, {} tasks released</span>"#,
435                    summary.workers_evicted, summary.tasks_released
436                ))
437            }
438        }
439        Err(e) => Html(format!(
440            r#"<span class="badge badge-error">Error: {}</span>"#,
441            html_escape(&e.to_string())
442        )),
443    }
444}
445
446/// Tasks page - serves the tasks list page.
447async fn tasks_page() -> Html<&'static str> {
448    Html(templates::TASKS_TEMPLATE)
449}
450
451/// Activity page - serves the activity feed page.
452async fn activity_page() -> Html<&'static str> {
453    Html(templates::ACTIVITY_TEMPLATE)
454}
455
456/// Query parameters for activity list API.
457#[derive(Debug, serde::Deserialize)]
458struct ActivityListParams {
459    event_type: Option<String>,
460    status: Option<String>,
461    worker: Option<String>,
462    task: Option<String>,
463    page: Option<i32>,
464    limit: Option<i32>,
465}
466
467/// Activity stats API endpoint for htmx - returns HTML fragment.
468async fn api_activity_stats(State(state): State<DashboardServer>) -> Html<String> {
469    let stats =
470        state
471            .db()
472            .get_activity_stats()
473            .unwrap_or_else(|_| crate::db::dashboard::ActivityStats {
474                total_events_24h: 0,
475                transitions_24h: 0,
476                file_events_24h: 0,
477                active_workers: 0,
478                events_by_status: std::collections::HashMap::new(),
479            });
480
481    Html(format!(
482        r#"
483        <div class="stats-row">
484            <div class="stat-card">
485                <div class="stat-value">{}</div>
486                <div class="stat-label">Events (24h)</div>
487            </div>
488            <div class="stat-card">
489                <div class="stat-value">{}</div>
490                <div class="stat-label">Task Transitions</div>
491            </div>
492            <div class="stat-card">
493                <div class="stat-value">{}</div>
494                <div class="stat-label">File Events</div>
495            </div>
496            <div class="stat-card">
497                <div class="stat-value">{}</div>
498                <div class="stat-label">Active Workers</div>
499            </div>
500        </div>
501    "#,
502        stats.total_events_24h, stats.transitions_24h, stats.file_events_24h, stats.active_workers
503    ))
504}
505
506/// Activity list API endpoint for htmx - returns HTML fragment.
507async fn api_activity_list(
508    State(state): State<DashboardServer>,
509    Query(params): Query<ActivityListParams>,
510) -> Html<String> {
511    let query = ActivityListQuery {
512        event_type: params.event_type.filter(|s| !s.is_empty()),
513        status: params.status.filter(|s| !s.is_empty()),
514        worker: params.worker.filter(|s| !s.is_empty()),
515        task: params.task.filter(|s| !s.is_empty()),
516        page: params.page.unwrap_or(1).max(1),
517        limit: params.limit.unwrap_or(50).clamp(10, 100),
518    };
519
520    let result = match state.db().query_activity(&query) {
521        Ok(r) => r,
522        Err(_) => {
523            return Html(r#"<div class="empty-state">Error loading activity</div>"#.to_string());
524        }
525    };
526
527    if result.events.is_empty() {
528        return Html(
529            r#"<div class="empty-state">No activity matches the current filters</div>"#.to_string(),
530        );
531    }
532
533    let now = now_ms();
534    let mut html = String::from(r#"<div class="activity-feed">"#);
535
536    for event in &result.events {
537        let (event_icon, event_class, event_label) = match event.event_type {
538            crate::db::dashboard::ActivityEventType::TaskTransition => {
539                let status = event.to_status.as_deref().unwrap_or("unknown");
540                let icon = match status {
541                    "completed" => "&#10003;",
542                    "working" => "&#9654;",
543                    "pending" => "&#9679;",
544                    "failed" => "&#10007;",
545                    "cancelled" => "&#10008;",
546                    "assigned" => "&#10148;",
547                    _ => "&#8594;",
548                };
549                (icon, "event-type-transition", status)
550            }
551            crate::db::dashboard::ActivityEventType::FileClaim => {
552                ("&#128274;", "event-type-claim", "claimed")
553            }
554            crate::db::dashboard::ActivityEventType::FileRelease => {
555                ("&#128275;", "event-type-release", "released")
556            }
557        };
558
559        // Build description
560        let description = match event.event_type {
561            crate::db::dashboard::ActivityEventType::TaskTransition => {
562                let task_id = event.task_id.as_deref().unwrap_or("unknown");
563                let task_short = if task_id.len() > 30 {
564                    format!("{}...", &task_id[..27])
565                } else {
566                    task_id.to_string()
567                };
568                let status = event.to_status.as_deref().unwrap_or("unknown");
569                format!(
570                    r#"Task <a href="/tasks/{}" class="task-link">{}</a> transitioned to <span class="badge badge-{}">{}</span>"#,
571                    html_escape(task_id),
572                    html_escape(&task_short),
573                    match status {
574                        "completed" => "success",
575                        "working" => "info",
576                        "pending" => "pending",
577                        "failed" => "error",
578                        "cancelled" => "warning",
579                        "assigned" => "assigned",
580                        _ => "warning",
581                    },
582                    status
583                )
584            }
585            crate::db::dashboard::ActivityEventType::FileClaim => {
586                let file_path = event.file_path.as_deref().unwrap_or("unknown");
587                let file_short = if file_path.len() > 40 {
588                    format!("...{}", &file_path[file_path.len() - 37..])
589                } else {
590                    file_path.to_string()
591                };
592                format!(
593                    r#"File <span class="file-path">{}</span> was marked"#,
594                    html_escape(&file_short)
595                )
596            }
597            crate::db::dashboard::ActivityEventType::FileRelease => {
598                let file_path = event.file_path.as_deref().unwrap_or("unknown");
599                let file_short = if file_path.len() > 40 {
600                    format!("...{}", &file_path[file_path.len() - 37..])
601                } else {
602                    file_path.to_string()
603                };
604                format!(
605                    r#"File <span class="file-path">{}</span> was unmarked"#,
606                    html_escape(&file_short)
607                )
608            }
609        };
610
611        // Worker info
612        let worker_html = match &event.worker_id {
613            Some(worker) => format!(
614                r#"by <a href="/workers" class="worker-link">{}</a>"#,
615                html_escape(worker)
616            ),
617            None => String::new(),
618        };
619
620        // Reason if available
621        let reason_html = match &event.reason {
622            Some(reason) if !reason.is_empty() => format!(
623                r#" <span class="activity-details">- {}</span>"#,
624                html_escape(reason)
625            ),
626            _ => String::new(),
627        };
628
629        // Time ago
630        let time_ago = format_time_ago(now - event.timestamp);
631
632        html.push_str(&format!(
633            r#"<div class="activity-item">
634                <span class="event-type {event_class}">
635                    <span class="event-icon">{event_icon}</span>
636                    {event_label}
637                </span>
638                <div class="activity-meta">
639                    <span class="activity-description">{description} {worker_html}</span>
640                    {reason_html}
641                </div>
642                <span class="activity-time" data-timestamp="{timestamp}">{time_text}</span>
643            </div>"#,
644            event_class = event_class,
645            event_icon = event_icon,
646            event_label = event_label,
647            description = description,
648            worker_html = worker_html,
649            reason_html = reason_html,
650            timestamp = event.timestamp,
651            time_text = time_ago.0,
652        ));
653    }
654
655    html.push_str("</div>");
656
657    // Pagination
658    if result.total_pages > 1 {
659        let start = ((result.page - 1) * result.limit + 1) as i64;
660        let end = (start - 1 + result.events.len() as i64).min(result.total);
661
662        html.push_str(&format!(
663            r#"<div class="pagination">
664                <div class="pagination-info">
665                    Showing {start} - {end} of {total} events
666                </div>
667                <div class="pagination-controls">
668                    <button onclick="goToPage(1)" {first_disabled}>First</button>
669                    <button onclick="goToPage({prev_page})" {prev_disabled}>Prev</button>
670                    <span class="page-number">{page}</span>
671                    <button onclick="goToPage({next_page})" {next_disabled}>Next</button>
672                    <button onclick="goToPage({total_pages})" {last_disabled}>Last</button>
673                </div>
674            </div>"#,
675            start = start,
676            end = end,
677            total = result.total,
678            page = result.page,
679            prev_page = (result.page - 1).max(1),
680            next_page = (result.page + 1).min(result.total_pages),
681            total_pages = result.total_pages,
682            first_disabled = if result.page <= 1 { "disabled" } else { "" },
683            prev_disabled = if result.page <= 1 { "disabled" } else { "" },
684            next_disabled = if result.page >= result.total_pages {
685                "disabled"
686            } else {
687                ""
688            },
689            last_disabled = if result.page >= result.total_pages {
690                "disabled"
691            } else {
692                ""
693            },
694        ));
695    }
696
697    Html(html)
698}
699
700/// Format a timestamp in milliseconds to ISO 8601.
701fn format_timestamp(ms: Option<i64>) -> String {
702    match ms {
703        Some(ts) => crate::types::ms_to_iso(ts),
704        None => "-".to_string(),
705    }
706}
707
708/// Task detail page - serves the full task view with edit form.
709async fn task_detail_page(
710    State(state): State<DashboardServer>,
711    Path(task_id): Path<String>,
712    Query(params): Query<std::collections::HashMap<String, String>>,
713) -> impl IntoResponse {
714    // Get task from database
715    let task = match state.db().get_task(&task_id) {
716        Ok(Some(t)) => t,
717        Ok(None) => {
718            return Html(format!(
719                r#"<!DOCTYPE html><html><head><title>Task Not Found</title></head>
720                <body style="background:#1a1a2e;color:#eaeaea;font-family:system-ui;padding:2rem;">
721                <h1>Task Not Found</h1><p>Task with ID '{}' does not exist.</p>
722                <a href="/tasks" style="color:#e94560;">Back to Tasks</a></body></html>"#,
723                html_escape(&task_id)
724            ));
725        }
726        Err(_) => {
727            return Html(
728                r#"<!DOCTYPE html><html><head><title>Error</title></head>
729                <body style="background:#1a1a2e;color:#eaeaea;font-family:system-ui;padding:2rem;">
730                <h1>Error</h1><p>Failed to load task.</p>
731                <a href="/tasks" style="color:#e94560;">Back to Tasks</a></body></html>"#
732                    .to_string(),
733            );
734        }
735    };
736
737    // Get parent task
738    let parent_html = match state.db().get_parent(&task_id) {
739        Ok(Some(parent_id)) => format!(
740            r#"<a href="/tasks/{}">{}</a>"#,
741            html_escape(&parent_id),
742            html_escape(&parent_id)
743        ),
744        _ => "-".to_string(),
745    };
746
747    // Get blocked by (tasks that block this one)
748    let blocked_by = state.db().get_blockers(&task_id).unwrap_or_default();
749    let blocked_by_html = if blocked_by.is_empty() {
750        r#"<li class="empty-state">No blocking dependencies</li>"#.to_string()
751    } else {
752        blocked_by
753            .iter()
754            .map(|id| {
755                format!(
756                    r#"<li><a href="/tasks/{}">{}</a></li>"#,
757                    html_escape(id),
758                    html_escape(id)
759                )
760            })
761            .collect::<Vec<_>>()
762            .join("\n")
763    };
764
765    // Get blocks (tasks this one blocks)
766    let blocks = state.db().get_blocking(&task_id).unwrap_or_default();
767    let blocks_html = if blocks.is_empty() {
768        r#"<li class="empty-state">No tasks blocked</li>"#.to_string()
769    } else {
770        blocks
771            .iter()
772            .map(|id| {
773                format!(
774                    r#"<li><a href="/tasks/{}">{}</a></li>"#,
775                    html_escape(id),
776                    html_escape(id)
777                )
778            })
779            .collect::<Vec<_>>()
780            .join("\n")
781    };
782
783    // Status badge class
784    let status_badge = match task.status.as_str() {
785        "completed" => "badge-success",
786        "working" => "badge-info",
787        "failed" => "badge-error",
788        "pending" => "badge-pending",
789        "assigned" => "badge-info",
790        "cancelled" => "badge-warning",
791        _ => "badge-warning",
792    };
793
794    // Tags display
795    let tags_html = if task.tags.is_empty() {
796        "-".to_string()
797    } else {
798        task.tags
799            .iter()
800            .map(|t| format!(r#"<span class="tag">{}</span>"#, html_escape(t)))
801            .collect::<Vec<_>>()
802            .join(" ")
803    };
804
805    let tags_raw = task.tags.join(", ");
806
807    // Owner display
808    let owner_html = task
809        .worker_id
810        .as_deref()
811        .map(html_escape)
812        .unwrap_or_else(|| "-".to_string());
813
814    // Title display
815    let title = task.title.as_str();
816    let title_display = if title.is_empty() { &task.id } else { title };
817
818    // Description
819    let description = task.description.as_deref().unwrap_or("");
820    let description_escaped = html_escape(description);
821
822    // Status select options
823    let status_pending = if task.status == "pending" {
824        "selected"
825    } else {
826        ""
827    };
828    let status_assigned = if task.status == "assigned" {
829        "selected"
830    } else {
831        ""
832    };
833    let status_working = if task.status == "working" {
834        "selected"
835    } else {
836        ""
837    };
838    let status_completed = if task.status == "completed" {
839        "selected"
840    } else {
841        ""
842    };
843    let status_failed = if task.status == "failed" {
844        "selected"
845    } else {
846        ""
847    };
848    let status_cancelled = if task.status == "cancelled" {
849        "selected"
850    } else {
851        ""
852    };
853
854    // Check for message from form submission
855    let message = params
856        .get("msg")
857        .map(|m| {
858            let (class, text) = if let Some(stripped) = m.strip_prefix("success:") {
859                ("message-success", stripped)
860            } else if let Some(stripped) = m.strip_prefix("error:") {
861                ("message-error", stripped)
862            } else {
863                ("message-success", m.as_str())
864            };
865            format!(
866                r#"<div class="message {}">{}</div>"#,
867                class,
868                html_escape(text)
869            )
870        })
871        .unwrap_or_default();
872
873    // Load and render template
874    let template = templates::TASK_DETAIL_TEMPLATE;
875    let html = template
876        .replace("{{task_id}}", &html_escape(&task.id))
877        .replace("{{task_title}}", &html_escape(title_display))
878        .replace("{{task_status}}", &task.status)
879        .replace("{{status_badge}}", status_badge)
880        .replace("{{task_priority}}", &task.priority.to_string())
881        .replace("{{task_owner}}", &owner_html)
882        .replace("{{task_parent}}", &parent_html)
883        .replace("{{task_tags}}", &tags_html)
884        .replace("{{task_tags_raw}}", &html_escape(&tags_raw))
885        .replace("{{task_description}}", &description_escaped)
886        .replace("{{task_description_raw}}", &html_escape(description))
887        .replace("{{created_at}}", &format_timestamp(Some(task.created_at)))
888        .replace("{{updated_at}}", &format_timestamp(Some(task.updated_at)))
889        .replace("{{started_at}}", &format_timestamp(task.started_at))
890        .replace("{{claimed_at}}", &format_timestamp(task.claimed_at))
891        .replace("{{completed_at}}", &format_timestamp(task.completed_at))
892        .replace("{{blocked_by}}", &blocked_by_html)
893        .replace("{{blocks}}", &blocks_html)
894        .replace("{{status_pending}}", status_pending)
895        .replace("{{status_assigned}}", status_assigned)
896        .replace("{{status_working}}", status_working)
897        .replace("{{status_completed}}", status_completed)
898        .replace("{{status_failed}}", status_failed)
899        .replace("{{status_cancelled}}", status_cancelled)
900        .replace("{{message}}", &message);
901
902    Html(html)
903}
904
905/// Form data for task updates.
906#[derive(Debug, serde::Deserialize)]
907struct TaskUpdateForm {
908    status: Option<String>,
909    priority: Option<i32>,
910    tags: Option<String>,
911    description: Option<String>,
912}
913
914/// Handle task update form submission.
915async fn task_update_handler(
916    State(state): State<DashboardServer>,
917    Path(task_id): Path<String>,
918    Form(form): Form<TaskUpdateForm>,
919) -> impl IntoResponse {
920    // Parse tags from comma-separated string
921    let new_tags: Option<Vec<String>> = form.tags.as_ref().map(|t| {
922        t.split(',')
923            .map(|s| s.trim().to_string())
924            .filter(|s| !s.is_empty())
925            .collect()
926    });
927
928    // Use dashboard-specific update method
929    match state.db().dashboard_update_task(
930        &task_id,
931        form.status.as_deref(),
932        form.priority,
933        form.description.as_deref(),
934        new_tags,
935    ) {
936        Ok(()) => Html(
937            r#"<div class="message message-success">Task updated successfully</div>"#.to_string(),
938        ),
939        Err(e) => Html(format!(
940            r#"<div class="message message-error">Failed to update task: {}</div>"#,
941            html_escape(&e.to_string())
942        )),
943    }
944}
945
946/// Handle task deletion.
947async fn task_delete_handler(
948    State(state): State<DashboardServer>,
949    Path(task_id): Path<String>,
950) -> impl IntoResponse {
951    match state.db().dashboard_delete_task(&task_id) {
952        Ok(()) => {
953            // Redirect to tasks list with success message
954            Redirect::to("/tasks?deleted=1")
955        }
956        Err(_) => {
957            // Redirect back to task with error message
958            Redirect::to(&format!("/tasks/{}?msg=error:Delete+failed", task_id))
959        }
960    }
961}
962
963/// Request body for bulk operations.
964#[derive(Debug, serde::Deserialize)]
965struct BulkOperationRequest {
966    action: String,
967    task_ids: Vec<String>,
968    status: Option<String>,
969}
970
971/// Response for bulk operations.
972#[derive(Debug, serde::Serialize)]
973struct BulkOperationResponse {
974    success: bool,
975    affected: usize,
976    #[serde(skip_serializing_if = "Option::is_none")]
977    error: Option<String>,
978}
979
980/// Query parameters for task list.
981#[derive(Debug, serde::Deserialize)]
982struct TaskListParams {
983    status: Option<String>,
984    phase: Option<String>,
985    tags: Option<String>,
986    parent: Option<String>,
987    owner: Option<String>,
988    /// If "true", show only timed states. If "false", show only untimed. If absent/empty, show all.
989    show_untimed: Option<String>,
990    sort: Option<String>,
991    page: Option<i32>,
992    limit: Option<i32>,
993}
994
995/// Task list API endpoint for htmx - returns HTML fragment with table and pagination.
996async fn api_tasks_list(
997    State(state): State<DashboardServer>,
998    Query(params): Query<TaskListParams>,
999) -> Html<String> {
1000    // Parse sort parameter (format: "field_direction", e.g., "priority_desc")
1001    let (sort_by, sort_order) = params
1002        .sort
1003        .as_deref()
1004        .and_then(|s| s.rsplit_once('_'))
1005        .map(|(field, order)| (field.to_string(), order.to_string()))
1006        .unwrap_or_else(|| ("priority".to_string(), "desc".to_string()));
1007
1008    // Determine timed filter based on show_untimed parameter
1009    // Default behavior: show only timed states (active work)
1010    // When show_untimed=true, show all states (no filter)
1011    let show_untimed = params
1012        .show_untimed
1013        .as_ref()
1014        .map(|s| s == "true" || s == "1")
1015        .unwrap_or(false);
1016
1017    let (timed_filter, timed_states) = if show_untimed {
1018        // Show all tasks (no filter)
1019        (None, Vec::new())
1020    } else {
1021        // Show only timed states (default - focus on active work)
1022        let timed: Vec<String> = state
1023            .states_config()
1024            .state_names()
1025            .into_iter()
1026            .filter(|s| state.states_config().is_timed_state(s))
1027            .map(|s| s.to_string())
1028            .collect();
1029        (Some(true), timed)
1030    };
1031
1032    let query = TaskListQuery {
1033        status: params.status.filter(|s| !s.is_empty()),
1034        phase: params.phase.filter(|s| !s.is_empty()),
1035        tags: params.tags.filter(|s| !s.is_empty()),
1036        parent: params.parent.filter(|s| !s.is_empty()),
1037        owner: params.owner.filter(|s| !s.is_empty()),
1038        timed_filter,
1039        timed_states,
1040        sort_by,
1041        sort_order,
1042        page: params.page.unwrap_or(1).max(1),
1043        limit: params.limit.unwrap_or(25).clamp(10, 100),
1044    };
1045
1046    let result = match state.db().query_tasks(&query) {
1047        Ok(r) => r,
1048        Err(_) => return Html(r#"<div class="empty-state">Error loading tasks</div>"#.to_string()),
1049    };
1050
1051    if result.tasks.is_empty() {
1052        return Html(
1053            r#"<div class="empty-state">No tasks match the current filters</div>"#.to_string(),
1054        );
1055    }
1056
1057    let mut html = String::from(
1058        r#"<table>
1059        <thead>
1060            <tr>
1061                <th class="checkbox-col"><input type="checkbox" id="select-all-checkbox" class="task-checkbox" onchange="onSelectAllChange(this)"></th>
1062                <th class="sortable">ID</th>
1063                <th class="sortable">Title</th>
1064                <th class="sortable">Status</th>
1065                <th class="sortable">Priority</th>
1066                <th>Tags</th>
1067                <th>Owner</th>
1068            </tr>
1069        </thead>
1070        <tbody>"#,
1071    );
1072
1073    for task in &result.tasks {
1074        let badge_class = match task.status.as_str() {
1075            "completed" => "badge-success",
1076            "working" => "badge-info",
1077            "failed" => "badge-error",
1078            "pending" => "badge-pending",
1079            "assigned" => "badge-assigned",
1080            "cancelled" => "badge-warning",
1081            _ => "badge-warning",
1082        };
1083
1084        let priority_class = if task.priority >= 8 {
1085            "priority-high"
1086        } else if task.priority >= 4 {
1087            "priority-normal"
1088        } else {
1089            "priority-low"
1090        };
1091
1092        let title_display = task
1093            .title
1094            .as_deref()
1095            .filter(|t| !t.is_empty())
1096            .map(|t| {
1097                if t.len() > 50 {
1098                    format!("{}...", &t[..47])
1099                } else {
1100                    t.to_string()
1101                }
1102            })
1103            .unwrap_or_else(|| "-".to_string());
1104
1105        // Parse tags (stored as JSON array string)
1106        let tags_html = if task.tags.is_empty() || task.tags == "[]" {
1107            String::new()
1108        } else {
1109            // Try to parse as JSON array, fall back to displaying as-is
1110            match serde_json::from_str::<Vec<String>>(&task.tags) {
1111                Ok(tags) => tags
1112                    .iter()
1113                    .take(3) // Limit to 3 visible tags
1114                    .map(|t| format!(r#"<span class="tag">{}</span>"#, html_escape(t)))
1115                    .collect::<Vec<_>>()
1116                    .join(""),
1117                Err(_) => task.tags.clone(),
1118            }
1119        };
1120
1121        let owner_display = task
1122            .worker_id
1123            .as_deref()
1124            .map(html_escape)
1125            .unwrap_or_else(|| "-".to_string());
1126
1127        html.push_str(&format!(
1128            r#"<tr>
1129                <td class="checkbox-col"><input type="checkbox" class="task-checkbox" data-task-id="{id}" onchange="onTaskCheckboxChange(this, '{id}')"></td>
1130                <td class="task-id"><a href="/tasks/{id}">{id_short}</a></td>
1131                <td class="task-title" title="{title_full}">{title}</td>
1132                <td><span class="badge {badge_class}">{status}</span></td>
1133                <td class="{priority_class}">{priority}</td>
1134                <td class="task-tags">{tags}</td>
1135                <td>{owner}</td>
1136            </tr>"#,
1137            id = html_escape(&task.id),
1138            id_short = if task.id.len() > 20 { format!("{}...", &task.id[..17]) } else { task.id.clone() },
1139            title = html_escape(&title_display),
1140            title_full = html_escape(task.title.as_deref().unwrap_or("")),
1141            badge_class = badge_class,
1142            status = task.status,
1143            priority_class = priority_class,
1144            priority = task.priority,
1145            tags = tags_html,
1146            owner = owner_display,
1147        ));
1148    }
1149
1150    html.push_str("</tbody></table>");
1151
1152    // Pagination
1153    let start = ((result.page - 1) * result.limit + 1) as i64;
1154    let end = (start - 1 + result.tasks.len() as i64).min(result.total);
1155
1156    html.push_str(&format!(
1157        r#"<div class="pagination">
1158            <div class="pagination-info">
1159                Showing {start} - {end} of {total} tasks
1160            </div>
1161            <div class="pagination-controls">
1162                <button onclick="goToPage(1)" {first_disabled}>First</button>
1163                <button onclick="goToPage({prev_page})" {prev_disabled}>Prev</button>
1164                <span class="page-number">{page}</span>
1165                <button onclick="goToPage({next_page})" {next_disabled}>Next</button>
1166                <button onclick="goToPage({total_pages})" {last_disabled}>Last</button>
1167            </div>
1168        </div>"#,
1169        start = start,
1170        end = end,
1171        total = result.total,
1172        page = result.page,
1173        prev_page = (result.page - 1).max(1),
1174        next_page = (result.page + 1).min(result.total_pages),
1175        total_pages = result.total_pages,
1176        first_disabled = if result.page <= 1 { "disabled" } else { "" },
1177        prev_disabled = if result.page <= 1 { "disabled" } else { "" },
1178        next_disabled = if result.page >= result.total_pages {
1179            "disabled"
1180        } else {
1181            ""
1182        },
1183        last_disabled = if result.page >= result.total_pages {
1184            "disabled"
1185        } else {
1186            ""
1187        },
1188    ));
1189
1190    Html(html)
1191}
1192
1193/// Query parameters for task search.
1194#[derive(Debug, serde::Deserialize)]
1195struct TaskSearchParams {
1196    q: Option<String>,
1197    status: Option<String>,
1198    limit: Option<i32>,
1199}
1200
1201/// Task search API endpoint for htmx - returns HTML fragment with search results.
1202/// When query is empty, returns all tasks (filtered by status if provided).
1203async fn api_tasks_search(
1204    State(state): State<DashboardServer>,
1205    Query(params): Query<TaskSearchParams>,
1206) -> Html<String> {
1207    let query = params.q.filter(|s| !s.is_empty());
1208
1209    // If no query text, fall back to listing all tasks with optional status filter
1210    if query.is_none() {
1211        let list_query = TaskListQuery {
1212            status: params.status.clone().filter(|s| !s.is_empty()),
1213            phase: None,
1214            tags: None,
1215            parent: None,
1216            owner: None,
1217            timed_filter: None, // Search shows all tasks
1218            timed_states: Vec::new(),
1219            sort_by: "priority".to_string(),
1220            sort_order: "desc".to_string(),
1221            page: 1,
1222            limit: params.limit.unwrap_or(50).clamp(10, 100),
1223        };
1224
1225        let result = match state.db().query_tasks(&list_query) {
1226            Ok(r) => r,
1227            Err(_) => {
1228                return Html(r#"<div class="empty-state">Error loading tasks</div>"#.to_string());
1229            }
1230        };
1231
1232        if result.tasks.is_empty() {
1233            return Html(r#"<div class="empty-state">No tasks found</div>"#.to_string());
1234        }
1235
1236        // Return simple table without search-specific columns (score)
1237        let mut html = format!(
1238            r#"<div style="margin-bottom: 1rem; color: var(--text-secondary);">
1239                Showing {} tasks{}
1240            </div>
1241            <table>
1242            <thead>
1243                <tr>
1244                    <th>ID</th>
1245                    <th>Title</th>
1246                    <th>Status</th>
1247                    <th>Priority</th>
1248                </tr>
1249            </thead>
1250            <tbody>"#,
1251            result.tasks.len(),
1252            params
1253                .status
1254                .as_ref()
1255                .map(|s| format!(" (status: {})", s))
1256                .unwrap_or_default()
1257        );
1258
1259        for task in &result.tasks {
1260            let badge_class = match task.status.as_str() {
1261                "completed" => "badge-success",
1262                "working" => "badge-info",
1263                "failed" => "badge-error",
1264                "pending" => "badge-pending",
1265                "assigned" => "badge-assigned",
1266                "cancelled" => "badge-warning",
1267                _ => "badge-warning",
1268            };
1269
1270            let title_display = task
1271                .title
1272                .as_deref()
1273                .filter(|t| !t.is_empty())
1274                .map(|t| {
1275                    if t.len() > 60 {
1276                        format!("{}...", &t[..57])
1277                    } else {
1278                        t.to_string()
1279                    }
1280                })
1281                .unwrap_or_else(|| "-".to_string());
1282
1283            html.push_str(&format!(
1284                r#"<tr>
1285                    <td class="task-id"><a href="/tasks/{id}">{id_short}</a></td>
1286                    <td class="task-title">{title}</td>
1287                    <td><span class="badge {badge_class}">{status}</span></td>
1288                    <td>{priority}</td>
1289                </tr>"#,
1290                id = html_escape(&task.id),
1291                id_short = if task.id.len() > 20 {
1292                    format!("{}...", &task.id[..17])
1293                } else {
1294                    task.id.clone()
1295                },
1296                title = html_escape(&title_display),
1297                badge_class = badge_class,
1298                status = task.status,
1299                priority = task.priority,
1300            ));
1301        }
1302
1303        html.push_str("</tbody></table>");
1304        return Html(html);
1305    }
1306
1307    let query = query.unwrap();
1308
1309    let status_filter = params.status.as_deref().filter(|s| !s.is_empty());
1310    let limit = params.limit.unwrap_or(50).clamp(10, 100);
1311
1312    let results = match state
1313        .db()
1314        .search_tasks(&query, Some(limit), 0, false, status_filter)
1315    {
1316        Ok(r) => r,
1317        Err(e) => {
1318            // Handle FTS5 query syntax errors gracefully
1319            let error_msg = e.to_string();
1320            if error_msg.contains("fts5")
1321                || error_msg.contains("syntax")
1322                || error_msg.contains("MATCH")
1323            {
1324                return Html(format!(
1325                    r#"<div class="empty-state">Invalid search syntax: {}<br><br>
1326                    <small>Try: simple words, "exact phrase", prefix*, title:word, AND/OR/NOT</small></div>"#,
1327                    html_escape(&error_msg)
1328                ));
1329            }
1330            return Html(format!(
1331                r#"<div class="empty-state">Search error: {}</div>"#,
1332                html_escape(&error_msg)
1333            ));
1334        }
1335    };
1336
1337    if results.is_empty() {
1338        return Html(format!(
1339            r#"<div class="empty-state">No tasks found matching "{}"</div>"#,
1340            html_escape(&query)
1341        ));
1342    }
1343
1344    let mut html = format!(
1345        r#"<div style="margin-bottom: 1rem; color: var(--text-secondary);">
1346            Found {} results for "{}"
1347        </div>
1348        <table>
1349        <thead>
1350            <tr>
1351                <th>ID</th>
1352                <th>Title/Snippet</th>
1353                <th>Status</th>
1354                <th>Score</th>
1355            </tr>
1356        </thead>
1357        <tbody>"#,
1358        results.len(),
1359        html_escape(&query)
1360    );
1361
1362    for result in &results {
1363        let badge_class = match result.status.as_str() {
1364            "completed" => "badge-success",
1365            "working" => "badge-info",
1366            "failed" => "badge-error",
1367            "pending" => "badge-pending",
1368            "assigned" => "badge-assigned",
1369            "cancelled" => "badge-warning",
1370            _ => "badge-warning",
1371        };
1372
1373        // Use title_snippet which has <mark> tags for highlighting
1374        let title_display = if result.title_snippet.is_empty() {
1375            html_escape(&result.title)
1376        } else {
1377            // title_snippet already has HTML <mark> tags, so don't escape the marks
1378            result
1379                .title_snippet
1380                .replace('<', "&lt;")
1381                .replace('>', "&gt;")
1382                .replace("&lt;mark&gt;", "<mark>")
1383                .replace("&lt;/mark&gt;", "</mark>")
1384        };
1385
1386        // Format score (lower is better in BM25)
1387        let score_display = format!("{:.2}", -result.score);
1388
1389        html.push_str(&format!(
1390            r#"<tr class="search-result">
1391                <td class="task-id"><a href="/tasks/{id}">{id_short}</a></td>
1392                <td class="task-title">{title}</td>
1393                <td><span class="badge {badge_class}">{status}</span></td>
1394                <td class="search-score">{score}</td>
1395            </tr>"#,
1396            id = html_escape(&result.task_id),
1397            id_short = if result.task_id.len() > 20 {
1398                format!("{}...", &result.task_id[..17])
1399            } else {
1400                result.task_id.clone()
1401            },
1402            title = title_display,
1403            badge_class = badge_class,
1404            status = result.status,
1405            score = score_display,
1406        ));
1407    }
1408
1409    html.push_str("</tbody></table>");
1410
1411    Html(html)
1412}
1413
1414/// API endpoint to get available phases (distinct phases from existing tasks).
1415async fn api_tasks_phases(State(state): State<DashboardServer>) -> Json<Vec<String>> {
1416    match state.db().get_available_phases() {
1417        Ok(phases) => Json(phases),
1418        Err(_) => Json(Vec::new()),
1419    }
1420}
1421
1422/// Response for states configuration endpoint.
1423#[derive(serde::Serialize)]
1424struct StatesConfigResponse {
1425    /// All valid state names.
1426    states: Vec<String>,
1427    /// States that are timed (time spent is tracked).
1428    timed_states: Vec<String>,
1429    /// States that are untimed.
1430    untimed_states: Vec<String>,
1431}
1432
1433/// API endpoint to get states configuration (for timed/untimed filtering).
1434async fn api_states_config(State(state): State<DashboardServer>) -> Json<StatesConfigResponse> {
1435    let config = state.states_config();
1436    let states: Vec<String> = config
1437        .state_names()
1438        .into_iter()
1439        .map(|s| s.to_string())
1440        .collect();
1441    let timed_states: Vec<String> = states
1442        .iter()
1443        .filter(|s| config.is_timed_state(s))
1444        .cloned()
1445        .collect();
1446    let untimed_states: Vec<String> = states
1447        .iter()
1448        .filter(|s| !config.is_timed_state(s))
1449        .cloned()
1450        .collect();
1451
1452    Json(StatesConfigResponse {
1453        states,
1454        timed_states,
1455        untimed_states,
1456    })
1457}
1458
1459/// Bulk operations API endpoint - handles delete, status change, and force-release.
1460async fn api_tasks_bulk(
1461    State(state): State<DashboardServer>,
1462    Json(request): Json<BulkOperationRequest>,
1463) -> Json<BulkOperationResponse> {
1464    if request.task_ids.is_empty() {
1465        return Json(BulkOperationResponse {
1466            success: false,
1467            affected: 0,
1468            error: Some("No task IDs provided".to_string()),
1469        });
1470    }
1471
1472    let mut affected = 0;
1473    let mut last_error: Option<String> = None;
1474
1475    match request.action.as_str() {
1476        "delete" => {
1477            for task_id in &request.task_ids {
1478                match state.db().dashboard_delete_task(task_id) {
1479                    Ok(()) => affected += 1,
1480                    Err(e) => last_error = Some(e.to_string()),
1481                }
1482            }
1483        }
1484        "change_status" => {
1485            let status = match &request.status {
1486                Some(s) => s.as_str(),
1487                None => {
1488                    return Json(BulkOperationResponse {
1489                        success: false,
1490                        affected: 0,
1491                        error: Some("No status provided for change_status action".to_string()),
1492                    });
1493                }
1494            };
1495            for task_id in &request.task_ids {
1496                match state
1497                    .db()
1498                    .dashboard_update_task(task_id, Some(status), None, None, None)
1499                {
1500                    Ok(()) => affected += 1,
1501                    Err(e) => last_error = Some(e.to_string()),
1502                }
1503            }
1504        }
1505        "force_release" => {
1506            // Force release claims by setting status to pending and clearing owner
1507            for task_id in &request.task_ids {
1508                match state.db().dashboard_force_release_task(task_id) {
1509                    Ok(()) => affected += 1,
1510                    Err(e) => last_error = Some(e.to_string()),
1511                }
1512            }
1513        }
1514        _ => {
1515            return Json(BulkOperationResponse {
1516                success: false,
1517                affected: 0,
1518                error: Some(format!("Unknown action: {}", request.action)),
1519            });
1520        }
1521    }
1522
1523    Json(BulkOperationResponse {
1524        success: affected > 0,
1525        affected,
1526        error: if affected < request.task_ids.len() {
1527            last_error
1528        } else {
1529            None
1530        },
1531    })
1532}
1533
1534/// Escape HTML special characters.
1535fn html_escape(s: &str) -> String {
1536    s.replace('&', "&amp;")
1537        .replace('<', "&lt;")
1538        .replace('>', "&gt;")
1539        .replace('"', "&quot;")
1540        .replace('\'', "&#39;")
1541}
1542
1543/// Health check endpoint.
1544async fn health() -> impl IntoResponse {
1545    Json(HealthResponse {
1546        status: "healthy",
1547        version: env!("CARGO_PKG_VERSION"),
1548    })
1549}
1550
1551/// API root - returns available endpoints.
1552async fn api_root() -> impl IntoResponse {
1553    Json(serde_json::json!({
1554        "version": env!("CARGO_PKG_VERSION"),
1555        "endpoints": {
1556            "health": "/api/health",
1557            "tasks": "/api/tasks (coming soon)",
1558            "agents": "/api/agents (coming soon)",
1559        }
1560    }))
1561}
1562
1563/// File marks page - serves the file marks coordination page.
1564async fn file_marks_page() -> Html<&'static str> {
1565    Html(templates::FILE_MARKS_TEMPLATE)
1566}
1567
1568/// Metrics page - serves the metrics dashboard page.
1569async fn metrics_page() -> Html<&'static str> {
1570    Html(templates::METRICS_TEMPLATE)
1571}
1572
1573/// File marks stats API endpoint for htmx - returns HTML fragment with stats.
1574async fn api_file_marks_stats(State(state): State<DashboardServer>) -> Html<String> {
1575    let stats = state.db().get_file_marks_stats().unwrap_or({
1576        crate::db::dashboard::FileMarksStats {
1577            total_marks: 0,
1578            unique_agents: 0,
1579            with_tasks: 0,
1580            stale_marks: 0,
1581        }
1582    });
1583
1584    Html(format!(
1585        r#"
1586        <div class="stats-row">
1587            <div class="stat-item">
1588                <div class="stat-value">{}</div>
1589                <div class="stat-label">Total Marks</div>
1590            </div>
1591            <div class="stat-item">
1592                <div class="stat-value">{}</div>
1593                <div class="stat-label">Unique Agents</div>
1594            </div>
1595            <div class="stat-item">
1596                <div class="stat-value">{}</div>
1597                <div class="stat-label">With Tasks</div>
1598            </div>
1599            <div class="stat-item">
1600                <div class="stat-value" style="color: {}">{}</div>
1601                <div class="stat-label">Stale (&gt;1h)</div>
1602            </div>
1603        </div>
1604    "#,
1605        stats.total_marks,
1606        stats.unique_agents,
1607        stats.with_tasks,
1608        if stats.stale_marks > 0 {
1609            "var(--warning)"
1610        } else {
1611            "var(--text-primary)"
1612        },
1613        stats.stale_marks
1614    ))
1615}
1616
1617/// File marks list API endpoint for htmx - returns HTML fragment with table.
1618async fn api_file_marks_list(State(state): State<DashboardServer>) -> Html<String> {
1619    let marks = state.db().get_all_file_marks().unwrap_or_default();
1620
1621    if marks.is_empty() {
1622        return Html(
1623            r#"<div class="empty-state">No file marks currently active</div>"#.to_string(),
1624        );
1625    }
1626
1627    let now = now_ms();
1628    let mut html = String::from(
1629        r#"<table>
1630        <thead>
1631            <tr>
1632                <th>File Path</th>
1633                <th>Agent</th>
1634                <th>Task</th>
1635                <th>Reason</th>
1636                <th>Age</th>
1637                <th>Actions</th>
1638            </tr>
1639        </thead>
1640        <tbody>"#,
1641    );
1642
1643    for mark in &marks {
1644        let age = now - mark.locked_at;
1645        let (age_text, age_class) = format_time_ago(age);
1646
1647        // Determine if mark is stale (older than 1 hour)
1648        let is_stale = age > 60 * 60 * 1000;
1649        let row_class = if is_stale { "stale-mark" } else { "" };
1650
1651        let task_html = mark
1652            .task_id
1653            .as_ref()
1654            .map(|t| {
1655                format!(
1656                    r#"<a href="/tasks/{}" class="task-link">{}</a>"#,
1657                    html_escape(t),
1658                    if t.len() > 20 {
1659                        format!("{}...", &t[..17])
1660                    } else {
1661                        t.clone()
1662                    }
1663                )
1664            })
1665            .unwrap_or_else(|| "-".to_string());
1666
1667        let reason_html = mark
1668            .reason
1669            .as_ref()
1670            .map(|r| format!(r#"<span class="reason-text">{}</span>"#, html_escape(r)))
1671            .unwrap_or_else(|| "-".to_string());
1672
1673        html.push_str(&format!(
1674            r##"<tr class="{row_class}">
1675                <td><span class="file-path">{file_path}</span></td>
1676                <td><span class="agent-link">{agent}</span></td>
1677                <td>{task}</td>
1678                <td>{reason}</td>
1679                <td><span class="time-ago {age_class}">{age_text}</span></td>
1680                <td>
1681                    <form hx-post="/api/file-marks/force-unmark"
1682                          hx-target="#file-marks-list"
1683                          hx-swap="innerHTML"
1684                          hx-confirm="Force-unmark this file? The agent will lose coordination.">
1685                        <input type="hidden" name="file_path" value="{file_path_raw}">
1686                        <button type="submit" class="btn btn-danger btn-sm">Force Unmark</button>
1687                    </form>
1688                </td>
1689            </tr>"##,
1690            row_class = row_class,
1691            file_path = html_escape(&mark.file_path),
1692            file_path_raw = html_escape(&mark.file_path),
1693            agent = html_escape(&mark.worker_id),
1694            task = task_html,
1695            reason = reason_html,
1696            age_class = age_class,
1697            age_text = age_text,
1698        ));
1699    }
1700
1701    html.push_str("</tbody></table>");
1702    Html(html)
1703}
1704
1705/// Form data for force-unmark endpoint.
1706#[derive(Debug, serde::Deserialize)]
1707struct ForceUnmarkForm {
1708    file_path: String,
1709}
1710
1711/// Force-unmark a file - admin operation to remove stale marks.
1712async fn api_file_marks_force_unmark(
1713    State(state): State<DashboardServer>,
1714    Form(form): Form<ForceUnmarkForm>,
1715) -> Html<String> {
1716    match state.db().force_unmark_file(&form.file_path) {
1717        Ok(true) => {
1718            // Return updated file marks list
1719            api_file_marks_list(State(state)).await
1720        }
1721        Ok(false) => Html(format!(
1722            r#"<div class="empty-state" style="color: var(--warning);">File mark not found: {}</div>"#,
1723            html_escape(&form.file_path)
1724        )),
1725        Err(e) => Html(format!(
1726            r#"<div class="empty-state" style="color: var(--accent);">Error removing mark: {}</div>"#,
1727            html_escape(&e.to_string())
1728        )),
1729    }
1730}
1731
1732// ========== METRICS API HANDLERS ==========
1733
1734/// Format milliseconds as human-readable duration.
1735fn format_duration(ms: i64) -> String {
1736    if ms < 1000 {
1737        format!("{}ms", ms)
1738    } else if ms < 60_000 {
1739        format!("{}s", ms / 1000)
1740    } else if ms < 3_600_000 {
1741        let mins = ms / 60_000;
1742        let secs = (ms % 60_000) / 1000;
1743        if secs > 0 {
1744            format!("{}m {}s", mins, secs)
1745        } else {
1746            format!("{}m", mins)
1747        }
1748    } else if ms < 86_400_000 {
1749        let hours = ms / 3_600_000;
1750        let mins = (ms % 3_600_000) / 60_000;
1751        if mins > 0 {
1752            format!("{}h {}m", hours, mins)
1753        } else {
1754            format!("{}h", hours)
1755        }
1756    } else {
1757        let days = ms / 86_400_000;
1758        let hours = (ms % 86_400_000) / 3_600_000;
1759        if hours > 0 {
1760            format!("{}d {}h", days, hours)
1761        } else {
1762            format!("{}d", days)
1763        }
1764    }
1765}
1766
1767/// Metrics overview API endpoint for htmx - returns HTML fragment with key stats.
1768async fn api_metrics_overview(State(state): State<DashboardServer>) -> Html<String> {
1769    let overview = state.db().get_metrics_overview().unwrap_or({
1770        crate::db::dashboard::MetricsOverview {
1771            total_tasks: 0,
1772            completed_tasks: 0,
1773            total_cost_usd: 0.0,
1774            total_time_ms: 0,
1775            total_points: 0,
1776            completed_points: 0,
1777        }
1778    });
1779
1780    let time_str = format_duration(overview.total_time_ms);
1781    let cost_str = if overview.total_cost_usd > 0.0 {
1782        format!("${:.2}", overview.total_cost_usd)
1783    } else {
1784        "$0.00".to_string()
1785    };
1786
1787    Html(format!(
1788        r#"
1789        <div class="grid grid-stats">
1790            <div class="card stat">
1791                <div class="stat-value">{}</div>
1792                <div class="stat-label">Total Tasks</div>
1793            </div>
1794            <div class="card stat">
1795                <div class="stat-value money">{}</div>
1796                <div class="stat-label">Total Cost</div>
1797            </div>
1798            <div class="card stat">
1799                <div class="stat-value time">{}</div>
1800                <div class="stat-label">Total Time</div>
1801            </div>
1802            <div class="card stat">
1803                <div class="stat-value">{}</div>
1804                <div class="stat-label">Completed</div>
1805            </div>
1806        </div>
1807    "#,
1808        overview.total_tasks, cost_str, time_str, overview.completed_tasks
1809    ))
1810}
1811
1812/// Metrics distribution API endpoint for htmx - returns status distribution chart.
1813async fn api_metrics_distribution(State(state): State<DashboardServer>) -> Html<String> {
1814    let distribution = state.db().get_status_distribution().unwrap_or_default();
1815
1816    if distribution.is_empty() {
1817        return Html(r#"<div class="empty-state">No tasks to display</div>"#.to_string());
1818    }
1819
1820    let total: i64 = distribution.values().sum();
1821    if total == 0 {
1822        return Html(r#"<div class="empty-state">No tasks to display</div>"#.to_string());
1823    }
1824
1825    // Build status bar
1826    let mut bar_html = String::from(r#"<div class="status-bar">"#);
1827
1828    // Order statuses for consistent display
1829    let statuses = [
1830        "pending",
1831        "assigned",
1832        "working",
1833        "completed",
1834        "failed",
1835        "cancelled",
1836    ];
1837
1838    for status in &statuses {
1839        if let Some(&count) = distribution.get(*status)
1840            && count > 0
1841        {
1842            bar_html.push_str(&format!(
1843                r#"<div class="status-segment {}" style="flex-grow: {};" title="{}: {}">{}</div>"#,
1844                status, count, status, count, count
1845            ));
1846        }
1847    }
1848
1849    bar_html.push_str("</div>");
1850
1851    // Build legend
1852    let mut legend_html = String::from(r#"<div class="status-legend">"#);
1853
1854    for status in &statuses {
1855        if let Some(&count) = distribution.get(*status)
1856            && count > 0
1857        {
1858            let percentage = (count as f64 / total as f64) * 100.0;
1859            legend_html.push_str(&format!(
1860                    r#"<div class="legend-item"><span class="legend-dot {}"></span>{}: {} ({:.1}%)</div>"#,
1861                    status,
1862                    status,
1863                    count,
1864                    percentage
1865                ));
1866        }
1867    }
1868
1869    legend_html.push_str("</div>");
1870
1871    Html(format!("{}{}", bar_html, legend_html))
1872}
1873
1874/// Query parameters for velocity endpoint.
1875#[derive(Debug, serde::Deserialize)]
1876struct VelocityParams {
1877    period: Option<String>,
1878}
1879
1880/// Metrics velocity API endpoint for htmx - returns velocity chart.
1881async fn api_metrics_velocity(
1882    State(state): State<DashboardServer>,
1883    Query(params): Query<VelocityParams>,
1884) -> Html<String> {
1885    let period = params.period.as_deref().unwrap_or("day");
1886    let num_periods = if period == "week" { 6 } else { 7 };
1887
1888    let velocity = state
1889        .db()
1890        .get_velocity(period, num_periods)
1891        .unwrap_or_default();
1892
1893    if velocity.is_empty() {
1894        return Html(r#"<div class="empty-state">No velocity data available</div>"#.to_string());
1895    }
1896
1897    // Find max for scaling
1898    let max_count = velocity
1899        .iter()
1900        .map(|v| v.completed_count)
1901        .max()
1902        .unwrap_or(1)
1903        .max(1);
1904
1905    let mut html = String::from(r#"<div class="velocity-bars">"#);
1906
1907    for point in &velocity {
1908        let width_percent = (point.completed_count as f64 / max_count as f64) * 100.0;
1909
1910        html.push_str(&format!(
1911            r#"<div class="velocity-row">
1912                <span class="velocity-label">{}</span>
1913                <div class="velocity-bar-container">
1914                    <div class="velocity-bar" style="width: {}%;">{}</div>
1915                </div>
1916                <span class="velocity-value">{}</span>
1917            </div>"#,
1918            html_escape(&point.period_label),
1919            width_percent,
1920            if point.completed_count > 0 {
1921                point.completed_count.to_string()
1922            } else {
1923                String::new()
1924            },
1925            point.completed_count
1926        ));
1927    }
1928
1929    html.push_str("</div>");
1930
1931    // Add summary stats
1932    let total_completed: i64 = velocity.iter().map(|v| v.completed_count).sum();
1933    let total_points: i64 = velocity.iter().map(|v| v.total_points).sum();
1934    let avg = total_completed as f64 / num_periods as f64;
1935
1936    html.push_str(&format!(
1937        r#"<div class="status-legend" style="margin-top: 1rem;">
1938            <div class="legend-item">Total: {} tasks</div>
1939            <div class="legend-item">Points: {}</div>
1940            <div class="legend-item">Avg: {:.1} per {}</div>
1941        </div>"#,
1942        total_completed, total_points, avg, period
1943    ));
1944
1945    Html(html)
1946}
1947
1948/// Metrics time-in-status API endpoint for htmx - returns time stats table.
1949async fn api_metrics_time_in_status(State(state): State<DashboardServer>) -> Html<String> {
1950    let time_stats = state.db().get_time_in_status().unwrap_or_default();
1951
1952    if time_stats.is_empty() {
1953        return Html(
1954            r#"<div class="empty-state">No time tracking data available</div>"#.to_string(),
1955        );
1956    }
1957
1958    let mut html = String::from(
1959        r#"<table>
1960        <thead>
1961            <tr>
1962                <th>Status</th>
1963                <th>Avg Duration</th>
1964                <th>Total Duration</th>
1965                <th>Transitions</th>
1966            </tr>
1967        </thead>
1968        <tbody>"#,
1969    );
1970
1971    for stat in &time_stats {
1972        html.push_str(&format!(
1973            r#"<tr>
1974                <td><span class="badge badge-{}">{}</span></td>
1975                <td class="time">{}</td>
1976                <td class="time">{}</td>
1977                <td class="number">{}</td>
1978            </tr>"#,
1979            match stat.status.as_str() {
1980                "completed" => "success",
1981                "working" => "info",
1982                "pending" => "pending",
1983                "failed" => "error",
1984                "cancelled" => "warning",
1985                "assigned" => "assigned",
1986                _ => "warning",
1987            },
1988            html_escape(&stat.status),
1989            format_duration(stat.avg_duration_ms),
1990            format_duration(stat.total_duration_ms),
1991            stat.transition_count
1992        ));
1993    }
1994
1995    html.push_str("</tbody></table>");
1996    Html(html)
1997}
1998
1999/// Metrics cost-by-agent API endpoint for htmx - returns agent cost table.
2000async fn api_metrics_cost_by_agent(State(state): State<DashboardServer>) -> Html<String> {
2001    let agent_stats = state.db().get_cost_by_agent().unwrap_or_default();
2002
2003    if agent_stats.is_empty() {
2004        return Html(
2005            r#"<div class="empty-state">No cost data by agent available</div>"#.to_string(),
2006        );
2007    }
2008
2009    let mut html = String::from(
2010        r#"<table>
2011        <thead>
2012            <tr>
2013                <th>Agent</th>
2014                <th>Cost</th>
2015                <th>Tasks</th>
2016                <th>Completed</th>
2017                <th>Time</th>
2018            </tr>
2019        </thead>
2020        <tbody>"#,
2021    );
2022
2023    for stat in &agent_stats {
2024        let cost_str = if stat.total_cost_usd > 0.0 {
2025            format!("${:.4}", stat.total_cost_usd)
2026        } else {
2027            "$0.00".to_string()
2028        };
2029
2030        html.push_str(&format!(
2031            r#"<tr>
2032                <td>{}</td>
2033                <td class="cost">{}</td>
2034                <td class="number">{}</td>
2035                <td class="number">{}</td>
2036                <td class="time">{}</td>
2037            </tr>"#,
2038            html_escape(&stat.worker_id),
2039            cost_str,
2040            stat.task_count,
2041            stat.completed_count,
2042            format_duration(stat.total_time_ms)
2043        ));
2044    }
2045
2046    html.push_str("</tbody></table>");
2047    Html(html)
2048}
2049
2050/// Metrics custom metrics API endpoint for htmx - returns custom metrics display.
2051async fn api_metrics_custom(State(state): State<DashboardServer>) -> Html<String> {
2052    let custom = state
2053        .db()
2054        .get_custom_metrics()
2055        .unwrap_or(crate::db::dashboard::CustomMetricsAggregate { metrics: [0; 8] });
2056
2057    // Check if all metrics are zero
2058    let has_data = custom.metrics.iter().any(|&m| m != 0);
2059
2060    if !has_data {
2061        return Html(r#"<div class="empty-state">No custom metrics recorded. Use log_metrics() to track custom values.</div>"#.to_string());
2062    }
2063
2064    let mut html = String::from(r#"<div class="metrics-row">"#);
2065
2066    for (i, &value) in custom.metrics.iter().enumerate() {
2067        html.push_str(&format!(
2068            r#"<div class="metric-box">
2069                <div class="value">{}</div>
2070                <div class="label">Metric {}</div>
2071            </div>"#,
2072            value, i
2073        ));
2074    }
2075
2076    html.push_str("</div>");
2077    Html(html)
2078}
2079
2080// ========== DEPENDENCY GRAPH HANDLERS ==========
2081
2082/// Dependency graph page - serves the graph visualization page.
2083async fn graph_page() -> Html<&'static str> {
2084    Html(templates::DEP_GRAPH_TEMPLATE)
2085}
2086
2087/// Query parameters for graph mermaid endpoint.
2088#[derive(Debug, serde::Deserialize)]
2089struct GraphMermaidParams {
2090    dep_type: Option<String>,
2091    focus: Option<String>,
2092    depth: Option<i32>,
2093    direction: Option<String>,
2094}
2095
2096/// Mermaid diagram response.
2097#[derive(Debug, serde::Serialize)]
2098struct MermaidResponse {
2099    diagram: String,
2100    node_count: usize,
2101    edge_count: usize,
2102    #[serde(skip_serializing_if = "Option::is_none")]
2103    error: Option<String>,
2104}
2105
2106/// Generate Mermaid diagram syntax from dependency graph.
2107fn generate_mermaid_diagram(
2108    graph: &crate::db::dashboard::DependencyGraph,
2109    direction: &str,
2110) -> String {
2111    if graph.nodes.is_empty() {
2112        return String::new();
2113    }
2114
2115    let mut diagram = format!("flowchart {}\n", direction);
2116
2117    // Define node styles based on status
2118    diagram.push_str("    %% Node styles\n");
2119    diagram.push_str("    classDef pending fill:#a0a0a0,stroke:#666,color:#000\n");
2120    diagram.push_str("    classDef assigned fill:#60a5fa,stroke:#3b82f6,color:#000\n");
2121    diagram.push_str("    classDef working fill:#60a5fa,stroke:#3b82f6,color:#000\n");
2122    diagram.push_str("    classDef completed fill:#4ade80,stroke:#22c55e,color:#000\n");
2123    diagram.push_str("    classDef failed fill:#e94560,stroke:#dc2626,color:#fff\n");
2124    diagram.push_str("    classDef cancelled fill:#fbbf24,stroke:#f59e0b,color:#000\n");
2125
2126    // Add nodes with escaped labels
2127    diagram.push_str("    %% Nodes\n");
2128    for node in &graph.nodes {
2129        // Sanitize node ID for mermaid (replace special chars)
2130        let safe_id = sanitize_mermaid_id(&node.id);
2131
2132        // Create a display label (truncate if too long)
2133        let display_label = if node.title.is_empty() {
2134            truncate_string(&node.id, 30)
2135        } else {
2136            truncate_string(&node.title, 30)
2137        };
2138
2139        // Escape quotes in label
2140        let escaped_label = display_label
2141            .replace('"', "'")
2142            .replace('<', "&lt;")
2143            .replace('>', "&gt;");
2144
2145        diagram.push_str(&format!("    {}[\"{}\"]\n", safe_id, escaped_label));
2146    }
2147
2148    // Add edges with different styles based on dependency type
2149    diagram.push_str("    %% Edges\n");
2150    for edge in &graph.edges {
2151        let from_safe = sanitize_mermaid_id(&edge.from_id);
2152        let to_safe = sanitize_mermaid_id(&edge.to_id);
2153
2154        let edge_style = match edge.dep_type.as_str() {
2155            "blocks" => "-->|blocks|",
2156            "follows" => "-.->|follows|",
2157            "contains" => "==>|contains|",
2158            _ => "-->",
2159        };
2160
2161        diagram.push_str(&format!("    {} {} {}\n", from_safe, edge_style, to_safe));
2162    }
2163
2164    // Apply classes based on status
2165    diagram.push_str("    %% Apply status classes\n");
2166    for node in &graph.nodes {
2167        let safe_id = sanitize_mermaid_id(&node.id);
2168        let class = match node.status.as_str() {
2169            "pending" => "pending",
2170            "assigned" => "assigned",
2171            "working" => "working",
2172            "completed" => "completed",
2173            "failed" => "failed",
2174            "cancelled" => "cancelled",
2175            _ => "pending",
2176        };
2177        diagram.push_str(&format!("    class {} {}\n", safe_id, class));
2178    }
2179
2180    diagram
2181}
2182
2183/// Sanitize a task ID for use as a mermaid node ID.
2184fn sanitize_mermaid_id(id: &str) -> String {
2185    id.chars()
2186        .map(|c| {
2187            if c.is_alphanumeric() || c == '_' {
2188                c
2189            } else {
2190                '_'
2191            }
2192        })
2193        .collect()
2194}
2195
2196/// Truncate a string to a maximum length, adding ellipsis if needed.
2197fn truncate_string(s: &str, max_len: usize) -> String {
2198    if s.len() <= max_len {
2199        s.to_string()
2200    } else {
2201        format!("{}...", &s[..max_len.saturating_sub(3)])
2202    }
2203}
2204
2205/// Graph mermaid API endpoint - returns mermaid diagram syntax.
2206async fn api_graph_mermaid(
2207    State(state): State<DashboardServer>,
2208    Query(params): Query<GraphMermaidParams>,
2209) -> Json<MermaidResponse> {
2210    let dep_type = params.dep_type.as_deref();
2211    let focus = params.focus.as_deref().filter(|s| !s.is_empty());
2212    let depth = params.depth.unwrap_or(2);
2213    let direction = params.direction.as_deref().unwrap_or("TB");
2214
2215    match state.db().get_dependency_graph(dep_type, focus, depth) {
2216        Ok(graph) => {
2217            let diagram = generate_mermaid_diagram(&graph, direction);
2218            Json(MermaidResponse {
2219                diagram,
2220                node_count: graph.nodes.len(),
2221                edge_count: graph.edges.len(),
2222                error: None,
2223            })
2224        }
2225        Err(e) => Json(MermaidResponse {
2226            diagram: String::new(),
2227            node_count: 0,
2228            edge_count: 0,
2229            error: Some(e.to_string()),
2230        }),
2231    }
2232}
2233
2234/// Graph stats API endpoint - returns HTML fragment with graph statistics.
2235async fn api_graph_stats(State(state): State<DashboardServer>) -> Html<String> {
2236    let stats = state.db().get_dependency_graph_stats().unwrap_or({
2237        crate::db::dashboard::DependencyGraphStats {
2238            total_tasks: 0,
2239            total_deps: 0,
2240            blocks_count: 0,
2241            follows_count: 0,
2242            contains_count: 0,
2243        }
2244    });
2245
2246    Html(format!(
2247        r#"
2248        <div class="stat-item">
2249            <div class="stat-value">{}</div>
2250            <div class="stat-label">Tasks</div>
2251        </div>
2252        <div class="stat-item">
2253            <div class="stat-value">{}</div>
2254            <div class="stat-label">Dependencies</div>
2255        </div>
2256        <div class="stat-item">
2257            <div class="stat-value">{}</div>
2258            <div class="stat-label">Blocks</div>
2259        </div>
2260        <div class="stat-item">
2261            <div class="stat-value">{}</div>
2262            <div class="stat-label">Follows</div>
2263        </div>
2264        <div class="stat-item">
2265            <div class="stat-value">{}</div>
2266            <div class="stat-label">Contains</div>
2267        </div>
2268    "#,
2269        stats.total_tasks,
2270        stats.total_deps,
2271        stats.blocks_count,
2272        stats.follows_count,
2273        stats.contains_count
2274    ))
2275}
2276
2277/// SQL query page - serves the SQL query interface for power users.
2278async fn sql_query_page() -> Html<&'static str> {
2279    Html(templates::SQL_QUERY_TEMPLATE)
2280}
2281
2282/// SQL query form data.
2283#[derive(Debug, serde::Deserialize)]
2284struct SqlQueryForm {
2285    sql: String,
2286    limit: Option<i32>,
2287}
2288
2289/// SQL query execute API endpoint - returns HTML fragment with results.
2290async fn api_sql_execute(
2291    State(state): State<DashboardServer>,
2292    Form(form): Form<SqlQueryForm>,
2293) -> Html<String> {
2294    use std::time::Duration;
2295
2296    // Validate the query is read-only
2297    let sql = form.sql.trim();
2298    if sql.is_empty() {
2299        return Html(r#"<div class="error-message">Please enter a SQL query.</div>"#.to_string());
2300    }
2301
2302    // Normalize and validate
2303    let normalized = sql.to_uppercase();
2304    let first_word = normalized.split_whitespace().next().unwrap_or("");
2305
2306    if first_word != "SELECT" && first_word != "WITH" {
2307        return Html(format!(
2308            r#"<div class="error-message">Only SELECT queries are allowed. Got: {}</div>"#,
2309            html_escape(first_word)
2310        ));
2311    }
2312
2313    // Check for forbidden statements
2314    let forbidden = [
2315        "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "REPLACE", "UPSERT",
2316        "MERGE", "GRANT", "REVOKE", "ATTACH", "DETACH", "VACUUM", "REINDEX", "ANALYZE", "PRAGMA",
2317    ];
2318
2319    for keyword in &forbidden {
2320        let pattern = format!(r"\b{}\s+", keyword);
2321        if let Ok(re) = regex_lite::Regex::new(&pattern)
2322            && re.is_match(&normalized)
2323        {
2324            return Html(format!(
2325                r#"<div class="error-message">{} statements are not allowed.</div>"#,
2326                keyword
2327            ));
2328        }
2329    }
2330
2331    // Check for multiple statements
2332    if sql.matches(';').count() > 1 {
2333        return Html(
2334            r#"<div class="error-message">Multiple SQL statements are not allowed.</div>"#
2335                .to_string(),
2336        );
2337    }
2338
2339    let limit = form.limit.map(|l| l.clamp(1, 1000)).unwrap_or(100);
2340
2341    // Execute the query
2342    let result = state.db().with_conn(|conn| {
2343        conn.busy_timeout(Duration::from_secs(5))?;
2344
2345        let mut stmt = conn.prepare(sql)?;
2346        let column_count = stmt.column_count();
2347        let columns: Vec<String> = (0..column_count)
2348            .map(|i| stmt.column_name(i).unwrap_or("?").to_string())
2349            .collect();
2350
2351        let mut rows: Vec<Vec<String>> = Vec::new();
2352        let mut row_iter = stmt.query([])?;
2353        let mut count = 0;
2354
2355        while let Some(row) = row_iter.next()? {
2356            if count >= limit {
2357                break;
2358            }
2359
2360            let mut row_values: Vec<String> = Vec::with_capacity(column_count);
2361            for i in 0..column_count {
2362                let value = match row.get_ref(i)? {
2363                    rusqlite::types::ValueRef::Null => "NULL".to_string(),
2364                    rusqlite::types::ValueRef::Integer(i) => i.to_string(),
2365                    rusqlite::types::ValueRef::Real(f) => f.to_string(),
2366                    rusqlite::types::ValueRef::Text(s) => String::from_utf8_lossy(s).to_string(),
2367                    rusqlite::types::ValueRef::Blob(b) => {
2368                        format!("[BLOB {} bytes]", b.len())
2369                    }
2370                };
2371                row_values.push(value);
2372            }
2373            rows.push(row_values);
2374            count += 1;
2375        }
2376
2377        let has_more = row_iter.next()?.is_some();
2378
2379        Ok((columns, rows, count, has_more))
2380    });
2381
2382    match result {
2383        Ok((columns, rows, row_count, truncated)) => {
2384            let mut html = String::new();
2385
2386            // Result stats
2387            html.push_str(r#"<div class="result-stats">"#);
2388            html.push_str(&format!(
2389                r#"<div class="result-stat"><span class="result-stat-label">Rows:</span> <span class="result-stat-value{}">{}{}</span></div>"#,
2390                if truncated { " truncated" } else { "" },
2391                row_count,
2392                if truncated { "+" } else { "" }
2393            ));
2394            html.push_str(&format!(
2395                r#"<div class="result-stat"><span class="result-stat-label">Columns:</span> <span class="result-stat-value">{}</span></div>"#,
2396                columns.len()
2397            ));
2398            if truncated {
2399                html.push_str(&format!(
2400                    r#"<div class="result-stat"><span class="result-stat-label">Limit:</span> <span class="result-stat-value">{}</span></div>"#,
2401                    limit
2402                ));
2403            }
2404            html.push_str("</div>");
2405
2406            // Results table
2407            html.push_str(r#"<div class="results-container"><table>"#);
2408
2409            // Header
2410            html.push_str("<thead><tr>");
2411            for col in &columns {
2412                html.push_str(&format!("<th>{}</th>", html_escape(col)));
2413            }
2414            html.push_str("</tr></thead>");
2415
2416            // Body
2417            html.push_str("<tbody>");
2418            if rows.is_empty() {
2419                html.push_str(&format!(
2420                    r#"<tr><td colspan="{}" class="empty-state">No rows returned</td></tr>"#,
2421                    columns.len().max(1)
2422                ));
2423            } else {
2424                for row in &rows {
2425                    html.push_str("<tr>");
2426                    for value in row {
2427                        if value == "NULL" {
2428                            html.push_str(r#"<td class="null-value">NULL</td>"#);
2429                        } else {
2430                            html.push_str(&format!("<td>{}</td>", html_escape(value)));
2431                        }
2432                    }
2433                    html.push_str("</tr>");
2434                }
2435            }
2436            html.push_str("</tbody></table></div>");
2437
2438            Html(html)
2439        }
2440        Err(e) => Html(format!(
2441            r#"<div class="error-message">Query Error: {}</div>"#,
2442            html_escape(&e.to_string())
2443        )),
2444    }
2445}
2446
2447/// SQL schema API endpoint - returns HTML fragment with schema reference.
2448async fn api_sql_schema(State(state): State<DashboardServer>) -> Html<String> {
2449    let schema_result = state.db().get_schema(false);
2450
2451    match schema_result {
2452        Ok(schema) => {
2453            let mut html = String::new();
2454
2455            for table in &schema.tables {
2456                // Table name header
2457                html.push_str(&format!(
2458                    r#"<div class="schema-table">
2459                        <div class="schema-table-name" onclick="toggleSchemaTable(this)">
2460                            <span class="toggle-icon">&#9660;</span> {}
2461                        </div>
2462                        <div class="schema-columns">"#,
2463                    html_escape(&table.name)
2464                ));
2465
2466                // Columns
2467                for col in &table.columns {
2468                    let pk_indicator = if col.primary_key {
2469                        r#"<span class="schema-column-pk">PK</span>"#
2470                    } else {
2471                        ""
2472                    };
2473                    let nullable = if col.nullable { "" } else { " NOT NULL" };
2474                    html.push_str(&format!(
2475                        r#"<div class="schema-column">
2476                            <span class="schema-column-name">{}</span>
2477                            <span class="schema-column-type">{}{}</span>
2478                            {}
2479                        </div>"#,
2480                        html_escape(&col.name),
2481                        html_escape(&col.data_type),
2482                        nullable,
2483                        pk_indicator
2484                    ));
2485                }
2486
2487                html.push_str("</div></div>");
2488            }
2489
2490            Html(html)
2491        }
2492        Err(e) => Html(format!(
2493            r#"<div class="error-message">Failed to load schema: {}</div>"#,
2494            html_escape(&e.to_string())
2495        )),
2496    }
2497}
2498
2499/// Build the router with all routes.
2500fn build_router(state: DashboardServer) -> Router {
2501    // Configure CORS for development
2502    let cors = CorsLayer::new()
2503        .allow_origin(Any)
2504        .allow_methods(Any)
2505        .allow_headers(Any);
2506
2507    Router::new()
2508        // Page routes
2509        .route("/", get(root))
2510        .route("/workers", get(workers_page))
2511        .route("/tasks", get(tasks_page))
2512        .route(
2513            "/tasks/{task_id}",
2514            get(task_detail_page)
2515                .post(task_update_handler)
2516                .delete(task_delete_handler),
2517        )
2518        .route("/activity", get(activity_page))
2519        .route("/file-marks", get(file_marks_page))
2520        .route("/metrics", get(metrics_page))
2521        .route("/graph", get(graph_page))
2522        .route("/sql", get(sql_query_page))
2523        // htmx fragment routes (for periodic refresh)
2524        .route("/api/stats", get(api_stats))
2525        .route("/api/tasks/recent", get(api_recent_tasks))
2526        .route("/api/tasks/list", get(api_tasks_list))
2527        .route("/api/tasks/search", get(api_tasks_search))
2528        .route("/api/tasks/phases", get(api_tasks_phases))
2529        .route("/api/states/config", get(api_states_config))
2530        .route("/api/tasks/bulk", post(api_tasks_bulk))
2531        .route("/api/workers/active", get(api_active_workers))
2532        .route("/api/workers/list", get(api_workers_list))
2533        .route("/api/workers/{worker_id}/details", get(api_worker_details))
2534        .route(
2535            "/api/workers/{worker_id}/disconnect",
2536            post(api_worker_disconnect),
2537        )
2538        .route("/api/workers/cleanup", post(api_workers_cleanup))
2539        .route("/api/activity/stats", get(api_activity_stats))
2540        .route("/api/activity/list", get(api_activity_list))
2541        .route("/api/file-marks/stats", get(api_file_marks_stats))
2542        .route("/api/file-marks/list", get(api_file_marks_list))
2543        .route(
2544            "/api/file-marks/force-unmark",
2545            post(api_file_marks_force_unmark),
2546        )
2547        // Metrics routes
2548        .route("/api/metrics/overview", get(api_metrics_overview))
2549        .route("/api/metrics/distribution", get(api_metrics_distribution))
2550        .route("/api/metrics/velocity", get(api_metrics_velocity))
2551        .route(
2552            "/api/metrics/time-in-status",
2553            get(api_metrics_time_in_status),
2554        )
2555        .route("/api/metrics/cost-by-agent", get(api_metrics_cost_by_agent))
2556        .route("/api/metrics/custom", get(api_metrics_custom))
2557        // Graph routes
2558        .route("/api/graph/mermaid", get(api_graph_mermaid))
2559        .route("/api/graph/stats", get(api_graph_stats))
2560        // SQL query routes
2561        .route("/api/sql/execute", post(api_sql_execute))
2562        .route("/api/sql/schema", get(api_sql_schema))
2563        // API routes
2564        .route("/api", get(api_root))
2565        .route("/api/health", get(health))
2566        // Add middleware
2567        .layer(cors)
2568        .layer(TraceLayer::new_for_http())
2569        .with_state(state)
2570}
2571
2572/// Status of the dashboard server.
2573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2574pub enum DashboardStatus {
2575    /// Dashboard is running and serving requests.
2576    Running,
2577    /// Dashboard failed to start, retrying in background.
2578    Retrying,
2579    /// Dashboard has been shut down.
2580    Stopped,
2581}
2582
2583/// Handle for managing the dashboard server lifecycle.
2584pub struct DashboardHandle {
2585    /// Channel to signal shutdown.
2586    shutdown_tx: Option<oneshot::Sender<()>>,
2587    /// Receiver for status updates.
2588    status_rx: watch::Receiver<DashboardStatus>,
2589}
2590
2591impl DashboardHandle {
2592    /// Get the current status of the dashboard.
2593    pub fn status(&self) -> DashboardStatus {
2594        *self.status_rx.borrow()
2595    }
2596
2597    /// Trigger shutdown of the dashboard server.
2598    pub fn shutdown(mut self) {
2599        if let Some(tx) = self.shutdown_tx.take() {
2600            let _ = tx.send(());
2601        }
2602    }
2603}
2604
2605/// Start the HTTP server on the specified port.
2606///
2607/// Returns a oneshot sender that can be used to signal shutdown,
2608/// and the actual address the server is bound to.
2609pub async fn start_server(
2610    db: Arc<Database>,
2611    port: u16,
2612    states_config: Arc<StatesConfig>,
2613) -> anyhow::Result<(oneshot::Sender<()>, SocketAddr)> {
2614    let state = DashboardServer::new(db, port, states_config);
2615    let app = build_router(state);
2616
2617    let addr = SocketAddr::from(([127, 0, 0, 1], port));
2618    let listener = tokio::net::TcpListener::bind(addr).await?;
2619    let bound_addr = listener.local_addr()?;
2620
2621    info!("Dashboard server listening on http://{}", bound_addr);
2622
2623    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
2624
2625    tokio::spawn(async move {
2626        if let Err(e) = axum::serve(listener, app)
2627            .with_graceful_shutdown(async {
2628                let _ = shutdown_rx.await;
2629                info!("Dashboard server shutting down");
2630            })
2631            .await
2632        {
2633            // Log error but don't crash - the main MCP server continues
2634            tracing::error!("Dashboard server error: {}", e);
2635        }
2636    });
2637
2638    Ok((shutdown_tx, bound_addr))
2639}
2640
2641/// Compute jittered delay for retry.
2642/// Uses system time nanoseconds for simple jitter without requiring rand crate.
2643fn compute_jittered_delay(base_ms: u64, jitter_ms: u64) -> Duration {
2644    use std::time::SystemTime;
2645
2646    let nanos = SystemTime::now()
2647        .duration_since(SystemTime::UNIX_EPOCH)
2648        .map(|d| d.subsec_nanos())
2649        .unwrap_or(0);
2650
2651    // Map nanos to range [-jitter_ms, +jitter_ms]
2652    let jitter_range = (jitter_ms * 2) as i64;
2653    let jitter = if jitter_range > 0 {
2654        (nanos as i64 % jitter_range) - (jitter_ms as i64)
2655    } else {
2656        0
2657    };
2658
2659    let delay_ms = (base_ms as i64 + jitter).max(1000) as u64; // At least 1 second
2660    Duration::from_millis(delay_ms)
2661}
2662
2663/// Start the HTTP server with automatic retry on failure.
2664///
2665/// This function never fails - if the port is in use, it will retry in the background
2666/// with exponential backoff. Returns a handle to monitor and control the dashboard.
2667///
2668/// # Arguments
2669/// * `db` - Database handle
2670/// * `ui_config` - UI configuration including port and retry settings
2671/// * `states_config` - States configuration for the dashboard
2672pub fn start_server_with_retry(
2673    db: Arc<Database>,
2674    ui_config: &UiConfig,
2675    states_config: Arc<StatesConfig>,
2676) -> DashboardHandle {
2677    let port = ui_config.port;
2678    let retry_initial_ms = ui_config.retry_initial_ms;
2679    let retry_jitter_ms = ui_config.retry_jitter_ms;
2680    let retry_max_ms = ui_config.retry_max_ms;
2681    let retry_multiplier = ui_config.retry_multiplier;
2682
2683    let (status_tx, status_rx) = watch::channel(DashboardStatus::Retrying);
2684    let (handle_shutdown_tx, mut handle_shutdown_rx) = oneshot::channel::<()>();
2685
2686    let db_clone = Arc::clone(&db);
2687    let states_config_clone = Arc::clone(&states_config);
2688
2689    tokio::spawn(async move {
2690        let mut current_delay_ms = retry_initial_ms;
2691        let mut server_shutdown_tx: Option<oneshot::Sender<()>> = None;
2692
2693        loop {
2694            // Check if we've been asked to shut down
2695            match handle_shutdown_rx.try_recv() {
2696                Ok(()) | Err(oneshot::error::TryRecvError::Closed) => {
2697                    info!("Dashboard retry loop shutting down");
2698                    if let Some(tx) = server_shutdown_tx.take() {
2699                        let _ = tx.send(());
2700                    }
2701                    let _ = status_tx.send(DashboardStatus::Stopped);
2702                    break;
2703                }
2704                Err(oneshot::error::TryRecvError::Empty) => {}
2705            }
2706
2707            // Try to start the server
2708            match start_server(
2709                Arc::clone(&db_clone),
2710                port,
2711                Arc::clone(&states_config_clone),
2712            )
2713            .await
2714            {
2715                Ok((shutdown_tx, bound_addr)) => {
2716                    info!("Dashboard available at http://{}", bound_addr);
2717                    let _ = status_tx.send(DashboardStatus::Running);
2718                    server_shutdown_tx = Some(shutdown_tx);
2719
2720                    // Wait for shutdown signal
2721                    let _ = handle_shutdown_rx.await;
2722                    info!("Dashboard handle shutdown received");
2723                    if let Some(tx) = server_shutdown_tx.take() {
2724                        let _ = tx.send(());
2725                    }
2726                    let _ = status_tx.send(DashboardStatus::Stopped);
2727                    break;
2728                }
2729                Err(e) => {
2730                    warn!(
2731                        "Failed to start dashboard on port {}: {}. Retrying in {:.1}s...",
2732                        port,
2733                        e,
2734                        current_delay_ms as f64 / 1000.0
2735                    );
2736                    let _ = status_tx.send(DashboardStatus::Retrying);
2737
2738                    // Wait with jitter
2739                    let delay = compute_jittered_delay(current_delay_ms, retry_jitter_ms);
2740                    tokio::time::sleep(delay).await;
2741
2742                    // Exponential backoff, capped at max
2743                    current_delay_ms =
2744                        ((current_delay_ms as f64 * retry_multiplier) as u64).min(retry_max_ms);
2745                }
2746            }
2747        }
2748    });
2749
2750    DashboardHandle {
2751        shutdown_tx: Some(handle_shutdown_tx),
2752        status_rx,
2753    }
2754}
2755
2756#[cfg(test)]
2757mod tests {
2758    use super::*;
2759
2760    #[test]
2761    fn test_health_response_serialization() {
2762        let response = HealthResponse {
2763            status: "healthy",
2764            version: "0.1.0",
2765        };
2766        let json = serde_json::to_string(&response).unwrap();
2767        assert!(json.contains("healthy"));
2768        assert!(json.contains("0.1.0"));
2769    }
2770}