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