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(html_escape)
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 let Some(stripped) = m.strip_prefix("success:") {
872                ("message-success", stripped)
873            } else if let Some(stripped) = m.strip_prefix("error:") {
874                ("message-error", stripped)
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(html_escape)
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), 0, 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({
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({
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            && count > 0
1854        {
1855            bar_html.push_str(&format!(
1856                r#"<div class="status-segment {}" style="flex-grow: {};" title="{}: {}">{}</div>"#,
1857                status, count, status, count, count
1858            ));
1859        }
1860    }
1861
1862    bar_html.push_str("</div>");
1863
1864    // Build legend
1865    let mut legend_html = String::from(r#"<div class="status-legend">"#);
1866
1867    for status in &statuses {
1868        if let Some(&count) = distribution.get(*status)
1869            && count > 0
1870        {
1871            let percentage = (count as f64 / total as f64) * 100.0;
1872            legend_html.push_str(&format!(
1873                    r#"<div class="legend-item"><span class="legend-dot {}"></span>{}: {} ({:.1}%)</div>"#,
1874                    status,
1875                    status,
1876                    count,
1877                    percentage
1878                ));
1879        }
1880    }
1881
1882    legend_html.push_str("</div>");
1883
1884    Html(format!("{}{}", bar_html, legend_html))
1885}
1886
1887/// Query parameters for velocity endpoint.
1888#[derive(Debug, serde::Deserialize)]
1889struct VelocityParams {
1890    period: Option<String>,
1891}
1892
1893/// Metrics velocity API endpoint for htmx - returns velocity chart.
1894async fn api_metrics_velocity(
1895    State(state): State<DashboardServer>,
1896    Query(params): Query<VelocityParams>,
1897) -> Html<String> {
1898    let period = params.period.as_deref().unwrap_or("day");
1899    let num_periods = if period == "week" { 6 } else { 7 };
1900
1901    let velocity = state
1902        .db()
1903        .get_velocity(period, num_periods)
1904        .unwrap_or_default();
1905
1906    if velocity.is_empty() {
1907        return Html(r#"<div class="empty-state">No velocity data available</div>"#.to_string());
1908    }
1909
1910    // Find max for scaling
1911    let max_count = velocity
1912        .iter()
1913        .map(|v| v.completed_count)
1914        .max()
1915        .unwrap_or(1)
1916        .max(1);
1917
1918    let mut html = String::from(r#"<div class="velocity-bars">"#);
1919
1920    for point in &velocity {
1921        let width_percent = (point.completed_count as f64 / max_count as f64) * 100.0;
1922
1923        html.push_str(&format!(
1924            r#"<div class="velocity-row">
1925                <span class="velocity-label">{}</span>
1926                <div class="velocity-bar-container">
1927                    <div class="velocity-bar" style="width: {}%;">{}</div>
1928                </div>
1929                <span class="velocity-value">{}</span>
1930            </div>"#,
1931            html_escape(&point.period_label),
1932            width_percent,
1933            if point.completed_count > 0 {
1934                point.completed_count.to_string()
1935            } else {
1936                String::new()
1937            },
1938            point.completed_count
1939        ));
1940    }
1941
1942    html.push_str("</div>");
1943
1944    // Add summary stats
1945    let total_completed: i64 = velocity.iter().map(|v| v.completed_count).sum();
1946    let total_points: i64 = velocity.iter().map(|v| v.total_points).sum();
1947    let avg = total_completed as f64 / num_periods as f64;
1948
1949    html.push_str(&format!(
1950        r#"<div class="status-legend" style="margin-top: 1rem;">
1951            <div class="legend-item">Total: {} tasks</div>
1952            <div class="legend-item">Points: {}</div>
1953            <div class="legend-item">Avg: {:.1} per {}</div>
1954        </div>"#,
1955        total_completed, total_points, avg, period
1956    ));
1957
1958    Html(html)
1959}
1960
1961/// Metrics time-in-status API endpoint for htmx - returns time stats table.
1962async fn api_metrics_time_in_status(State(state): State<DashboardServer>) -> Html<String> {
1963    let time_stats = state.db().get_time_in_status().unwrap_or_default();
1964
1965    if time_stats.is_empty() {
1966        return Html(
1967            r#"<div class="empty-state">No time tracking data available</div>"#.to_string(),
1968        );
1969    }
1970
1971    let mut html = String::from(
1972        r#"<table>
1973        <thead>
1974            <tr>
1975                <th>Status</th>
1976                <th>Avg Duration</th>
1977                <th>Total Duration</th>
1978                <th>Transitions</th>
1979            </tr>
1980        </thead>
1981        <tbody>"#,
1982    );
1983
1984    for stat in &time_stats {
1985        html.push_str(&format!(
1986            r#"<tr>
1987                <td><span class="badge badge-{}">{}</span></td>
1988                <td class="time">{}</td>
1989                <td class="time">{}</td>
1990                <td class="number">{}</td>
1991            </tr>"#,
1992            match stat.status.as_str() {
1993                "completed" => "success",
1994                "working" => "info",
1995                "pending" => "pending",
1996                "failed" => "error",
1997                "cancelled" => "warning",
1998                "assigned" => "assigned",
1999                _ => "warning",
2000            },
2001            html_escape(&stat.status),
2002            format_duration(stat.avg_duration_ms),
2003            format_duration(stat.total_duration_ms),
2004            stat.transition_count
2005        ));
2006    }
2007
2008    html.push_str("</tbody></table>");
2009    Html(html)
2010}
2011
2012/// Metrics cost-by-agent API endpoint for htmx - returns agent cost table.
2013async fn api_metrics_cost_by_agent(State(state): State<DashboardServer>) -> Html<String> {
2014    let agent_stats = state.db().get_cost_by_agent().unwrap_or_default();
2015
2016    if agent_stats.is_empty() {
2017        return Html(
2018            r#"<div class="empty-state">No cost data by agent available</div>"#.to_string(),
2019        );
2020    }
2021
2022    let mut html = String::from(
2023        r#"<table>
2024        <thead>
2025            <tr>
2026                <th>Agent</th>
2027                <th>Cost</th>
2028                <th>Tasks</th>
2029                <th>Completed</th>
2030                <th>Time</th>
2031            </tr>
2032        </thead>
2033        <tbody>"#,
2034    );
2035
2036    for stat in &agent_stats {
2037        let cost_str = if stat.total_cost_usd > 0.0 {
2038            format!("${:.4}", stat.total_cost_usd)
2039        } else {
2040            "$0.00".to_string()
2041        };
2042
2043        html.push_str(&format!(
2044            r#"<tr>
2045                <td>{}</td>
2046                <td class="cost">{}</td>
2047                <td class="number">{}</td>
2048                <td class="number">{}</td>
2049                <td class="time">{}</td>
2050            </tr>"#,
2051            html_escape(&stat.worker_id),
2052            cost_str,
2053            stat.task_count,
2054            stat.completed_count,
2055            format_duration(stat.total_time_ms)
2056        ));
2057    }
2058
2059    html.push_str("</tbody></table>");
2060    Html(html)
2061}
2062
2063/// Metrics custom metrics API endpoint for htmx - returns custom metrics display.
2064async fn api_metrics_custom(State(state): State<DashboardServer>) -> Html<String> {
2065    let custom = state
2066        .db()
2067        .get_custom_metrics()
2068        .unwrap_or(crate::db::dashboard::CustomMetricsAggregate { metrics: [0; 8] });
2069
2070    // Check if all metrics are zero
2071    let has_data = custom.metrics.iter().any(|&m| m != 0);
2072
2073    if !has_data {
2074        return Html(r#"<div class="empty-state">No custom metrics recorded. Use log_metrics() to track custom values.</div>"#.to_string());
2075    }
2076
2077    let mut html = String::from(r#"<div class="metrics-row">"#);
2078
2079    for (i, &value) in custom.metrics.iter().enumerate() {
2080        html.push_str(&format!(
2081            r#"<div class="metric-box">
2082                <div class="value">{}</div>
2083                <div class="label">Metric {}</div>
2084            </div>"#,
2085            value, i
2086        ));
2087    }
2088
2089    html.push_str("</div>");
2090    Html(html)
2091}
2092
2093// ========== DEPENDENCY GRAPH HANDLERS ==========
2094
2095/// Dependency graph page - serves the graph visualization page.
2096async fn graph_page() -> Html<&'static str> {
2097    Html(templates::DEP_GRAPH_TEMPLATE)
2098}
2099
2100/// Query parameters for graph mermaid endpoint.
2101#[derive(Debug, serde::Deserialize)]
2102struct GraphMermaidParams {
2103    dep_type: Option<String>,
2104    focus: Option<String>,
2105    depth: Option<i32>,
2106    direction: Option<String>,
2107}
2108
2109/// Mermaid diagram response.
2110#[derive(Debug, serde::Serialize)]
2111struct MermaidResponse {
2112    diagram: String,
2113    node_count: usize,
2114    edge_count: usize,
2115    #[serde(skip_serializing_if = "Option::is_none")]
2116    error: Option<String>,
2117}
2118
2119/// Generate Mermaid diagram syntax from dependency graph.
2120fn generate_mermaid_diagram(
2121    graph: &crate::db::dashboard::DependencyGraph,
2122    direction: &str,
2123) -> String {
2124    if graph.nodes.is_empty() {
2125        return String::new();
2126    }
2127
2128    let mut diagram = format!("flowchart {}\n", direction);
2129
2130    // Define node styles based on status
2131    diagram.push_str("    %% Node styles\n");
2132    diagram.push_str("    classDef pending fill:#a0a0a0,stroke:#666,color:#000\n");
2133    diagram.push_str("    classDef assigned fill:#60a5fa,stroke:#3b82f6,color:#000\n");
2134    diagram.push_str("    classDef working fill:#60a5fa,stroke:#3b82f6,color:#000\n");
2135    diagram.push_str("    classDef completed fill:#4ade80,stroke:#22c55e,color:#000\n");
2136    diagram.push_str("    classDef failed fill:#e94560,stroke:#dc2626,color:#fff\n");
2137    diagram.push_str("    classDef cancelled fill:#fbbf24,stroke:#f59e0b,color:#000\n");
2138
2139    // Add nodes with escaped labels
2140    diagram.push_str("    %% Nodes\n");
2141    for node in &graph.nodes {
2142        // Sanitize node ID for mermaid (replace special chars)
2143        let safe_id = sanitize_mermaid_id(&node.id);
2144
2145        // Create a display label (truncate if too long)
2146        let display_label = if node.title.is_empty() {
2147            truncate_string(&node.id, 30)
2148        } else {
2149            truncate_string(&node.title, 30)
2150        };
2151
2152        // Escape quotes in label
2153        let escaped_label = display_label
2154            .replace('"', "'")
2155            .replace('<', "&lt;")
2156            .replace('>', "&gt;");
2157
2158        diagram.push_str(&format!("    {}[\"{}\"]\n", safe_id, escaped_label));
2159    }
2160
2161    // Add edges with different styles based on dependency type
2162    diagram.push_str("    %% Edges\n");
2163    for edge in &graph.edges {
2164        let from_safe = sanitize_mermaid_id(&edge.from_id);
2165        let to_safe = sanitize_mermaid_id(&edge.to_id);
2166
2167        let edge_style = match edge.dep_type.as_str() {
2168            "blocks" => "-->|blocks|",
2169            "follows" => "-.->|follows|",
2170            "contains" => "==>|contains|",
2171            _ => "-->",
2172        };
2173
2174        diagram.push_str(&format!("    {} {} {}\n", from_safe, edge_style, to_safe));
2175    }
2176
2177    // Apply classes based on status
2178    diagram.push_str("    %% Apply status classes\n");
2179    for node in &graph.nodes {
2180        let safe_id = sanitize_mermaid_id(&node.id);
2181        let class = match node.status.as_str() {
2182            "pending" => "pending",
2183            "assigned" => "assigned",
2184            "working" => "working",
2185            "completed" => "completed",
2186            "failed" => "failed",
2187            "cancelled" => "cancelled",
2188            _ => "pending",
2189        };
2190        diagram.push_str(&format!("    class {} {}\n", safe_id, class));
2191    }
2192
2193    diagram
2194}
2195
2196/// Sanitize a task ID for use as a mermaid node ID.
2197fn sanitize_mermaid_id(id: &str) -> String {
2198    id.chars()
2199        .map(|c| {
2200            if c.is_alphanumeric() || c == '_' {
2201                c
2202            } else {
2203                '_'
2204            }
2205        })
2206        .collect()
2207}
2208
2209/// Truncate a string to a maximum length, adding ellipsis if needed.
2210fn truncate_string(s: &str, max_len: usize) -> String {
2211    if s.len() <= max_len {
2212        s.to_string()
2213    } else {
2214        format!("{}...", &s[..max_len.saturating_sub(3)])
2215    }
2216}
2217
2218/// Graph mermaid API endpoint - returns mermaid diagram syntax.
2219async fn api_graph_mermaid(
2220    State(state): State<DashboardServer>,
2221    Query(params): Query<GraphMermaidParams>,
2222) -> Json<MermaidResponse> {
2223    let dep_type = params.dep_type.as_deref();
2224    let focus = params.focus.as_deref().filter(|s| !s.is_empty());
2225    let depth = params.depth.unwrap_or(2);
2226    let direction = params.direction.as_deref().unwrap_or("TB");
2227
2228    match state.db().get_dependency_graph(dep_type, focus, depth) {
2229        Ok(graph) => {
2230            let diagram = generate_mermaid_diagram(&graph, direction);
2231            Json(MermaidResponse {
2232                diagram,
2233                node_count: graph.nodes.len(),
2234                edge_count: graph.edges.len(),
2235                error: None,
2236            })
2237        }
2238        Err(e) => Json(MermaidResponse {
2239            diagram: String::new(),
2240            node_count: 0,
2241            edge_count: 0,
2242            error: Some(e.to_string()),
2243        }),
2244    }
2245}
2246
2247/// Graph stats API endpoint - returns HTML fragment with graph statistics.
2248async fn api_graph_stats(State(state): State<DashboardServer>) -> Html<String> {
2249    let stats = state.db().get_dependency_graph_stats().unwrap_or({
2250        crate::db::dashboard::DependencyGraphStats {
2251            total_tasks: 0,
2252            total_deps: 0,
2253            blocks_count: 0,
2254            follows_count: 0,
2255            contains_count: 0,
2256        }
2257    });
2258
2259    Html(format!(
2260        r#"
2261        <div class="stat-item">
2262            <div class="stat-value">{}</div>
2263            <div class="stat-label">Tasks</div>
2264        </div>
2265        <div class="stat-item">
2266            <div class="stat-value">{}</div>
2267            <div class="stat-label">Dependencies</div>
2268        </div>
2269        <div class="stat-item">
2270            <div class="stat-value">{}</div>
2271            <div class="stat-label">Blocks</div>
2272        </div>
2273        <div class="stat-item">
2274            <div class="stat-value">{}</div>
2275            <div class="stat-label">Follows</div>
2276        </div>
2277        <div class="stat-item">
2278            <div class="stat-value">{}</div>
2279            <div class="stat-label">Contains</div>
2280        </div>
2281    "#,
2282        stats.total_tasks,
2283        stats.total_deps,
2284        stats.blocks_count,
2285        stats.follows_count,
2286        stats.contains_count
2287    ))
2288}
2289
2290/// SQL query page - serves the SQL query interface for power users.
2291async fn sql_query_page() -> Html<&'static str> {
2292    Html(templates::SQL_QUERY_TEMPLATE)
2293}
2294
2295/// SQL query form data.
2296#[derive(Debug, serde::Deserialize)]
2297struct SqlQueryForm {
2298    sql: String,
2299    limit: Option<i32>,
2300}
2301
2302/// SQL query execute API endpoint - returns HTML fragment with results.
2303async fn api_sql_execute(
2304    State(state): State<DashboardServer>,
2305    Form(form): Form<SqlQueryForm>,
2306) -> Html<String> {
2307    use std::time::Duration;
2308
2309    // Validate the query is read-only
2310    let sql = form.sql.trim();
2311    if sql.is_empty() {
2312        return Html(r#"<div class="error-message">Please enter a SQL query.</div>"#.to_string());
2313    }
2314
2315    // Normalize and validate
2316    let normalized = sql.to_uppercase();
2317    let first_word = normalized.split_whitespace().next().unwrap_or("");
2318
2319    if first_word != "SELECT" && first_word != "WITH" {
2320        return Html(format!(
2321            r#"<div class="error-message">Only SELECT queries are allowed. Got: {}</div>"#,
2322            html_escape(first_word)
2323        ));
2324    }
2325
2326    // Check for forbidden statements
2327    let forbidden = [
2328        "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "REPLACE", "UPSERT",
2329        "MERGE", "GRANT", "REVOKE", "ATTACH", "DETACH", "VACUUM", "REINDEX", "ANALYZE", "PRAGMA",
2330    ];
2331
2332    for keyword in &forbidden {
2333        let pattern = format!(r"\b{}\s+", keyword);
2334        if let Ok(re) = regex_lite::Regex::new(&pattern)
2335            && re.is_match(&normalized)
2336        {
2337            return Html(format!(
2338                r#"<div class="error-message">{} statements are not allowed.</div>"#,
2339                keyword
2340            ));
2341        }
2342    }
2343
2344    // Check for multiple statements
2345    if sql.matches(';').count() > 1 {
2346        return Html(
2347            r#"<div class="error-message">Multiple SQL statements are not allowed.</div>"#
2348                .to_string(),
2349        );
2350    }
2351
2352    let limit = form.limit.map(|l| l.clamp(1, 1000)).unwrap_or(100);
2353
2354    // Execute the query
2355    let result = state.db().with_conn(|conn| {
2356        conn.busy_timeout(Duration::from_secs(5))?;
2357
2358        let mut stmt = conn.prepare(sql)?;
2359        let column_count = stmt.column_count();
2360        let columns: Vec<String> = (0..column_count)
2361            .map(|i| stmt.column_name(i).unwrap_or("?").to_string())
2362            .collect();
2363
2364        let mut rows: Vec<Vec<String>> = Vec::new();
2365        let mut row_iter = stmt.query([])?;
2366        let mut count = 0;
2367
2368        while let Some(row) = row_iter.next()? {
2369            if count >= limit {
2370                break;
2371            }
2372
2373            let mut row_values: Vec<String> = Vec::with_capacity(column_count);
2374            for i in 0..column_count {
2375                let value = match row.get_ref(i)? {
2376                    rusqlite::types::ValueRef::Null => "NULL".to_string(),
2377                    rusqlite::types::ValueRef::Integer(i) => i.to_string(),
2378                    rusqlite::types::ValueRef::Real(f) => f.to_string(),
2379                    rusqlite::types::ValueRef::Text(s) => String::from_utf8_lossy(s).to_string(),
2380                    rusqlite::types::ValueRef::Blob(b) => {
2381                        format!("[BLOB {} bytes]", b.len())
2382                    }
2383                };
2384                row_values.push(value);
2385            }
2386            rows.push(row_values);
2387            count += 1;
2388        }
2389
2390        let has_more = row_iter.next()?.is_some();
2391
2392        Ok((columns, rows, count, has_more))
2393    });
2394
2395    match result {
2396        Ok((columns, rows, row_count, truncated)) => {
2397            let mut html = String::new();
2398
2399            // Result stats
2400            html.push_str(r#"<div class="result-stats">"#);
2401            html.push_str(&format!(
2402                r#"<div class="result-stat"><span class="result-stat-label">Rows:</span> <span class="result-stat-value{}">{}{}</span></div>"#,
2403                if truncated { " truncated" } else { "" },
2404                row_count,
2405                if truncated { "+" } else { "" }
2406            ));
2407            html.push_str(&format!(
2408                r#"<div class="result-stat"><span class="result-stat-label">Columns:</span> <span class="result-stat-value">{}</span></div>"#,
2409                columns.len()
2410            ));
2411            if truncated {
2412                html.push_str(&format!(
2413                    r#"<div class="result-stat"><span class="result-stat-label">Limit:</span> <span class="result-stat-value">{}</span></div>"#,
2414                    limit
2415                ));
2416            }
2417            html.push_str("</div>");
2418
2419            // Results table
2420            html.push_str(r#"<div class="results-container"><table>"#);
2421
2422            // Header
2423            html.push_str("<thead><tr>");
2424            for col in &columns {
2425                html.push_str(&format!("<th>{}</th>", html_escape(col)));
2426            }
2427            html.push_str("</tr></thead>");
2428
2429            // Body
2430            html.push_str("<tbody>");
2431            if rows.is_empty() {
2432                html.push_str(&format!(
2433                    r#"<tr><td colspan="{}" class="empty-state">No rows returned</td></tr>"#,
2434                    columns.len().max(1)
2435                ));
2436            } else {
2437                for row in &rows {
2438                    html.push_str("<tr>");
2439                    for value in row {
2440                        if value == "NULL" {
2441                            html.push_str(r#"<td class="null-value">NULL</td>"#);
2442                        } else {
2443                            html.push_str(&format!("<td>{}</td>", html_escape(value)));
2444                        }
2445                    }
2446                    html.push_str("</tr>");
2447                }
2448            }
2449            html.push_str("</tbody></table></div>");
2450
2451            Html(html)
2452        }
2453        Err(e) => Html(format!(
2454            r#"<div class="error-message">Query Error: {}</div>"#,
2455            html_escape(&e.to_string())
2456        )),
2457    }
2458}
2459
2460/// SQL schema API endpoint - returns HTML fragment with schema reference.
2461async fn api_sql_schema(State(state): State<DashboardServer>) -> Html<String> {
2462    let schema_result = state.db().get_schema(false);
2463
2464    match schema_result {
2465        Ok(schema) => {
2466            let mut html = String::new();
2467
2468            for table in &schema.tables {
2469                // Table name header
2470                html.push_str(&format!(
2471                    r#"<div class="schema-table">
2472                        <div class="schema-table-name" onclick="toggleSchemaTable(this)">
2473                            <span class="toggle-icon">&#9660;</span> {}
2474                        </div>
2475                        <div class="schema-columns">"#,
2476                    html_escape(&table.name)
2477                ));
2478
2479                // Columns
2480                for col in &table.columns {
2481                    let pk_indicator = if col.primary_key {
2482                        r#"<span class="schema-column-pk">PK</span>"#
2483                    } else {
2484                        ""
2485                    };
2486                    let nullable = if col.nullable { "" } else { " NOT NULL" };
2487                    html.push_str(&format!(
2488                        r#"<div class="schema-column">
2489                            <span class="schema-column-name">{}</span>
2490                            <span class="schema-column-type">{}{}</span>
2491                            {}
2492                        </div>"#,
2493                        html_escape(&col.name),
2494                        html_escape(&col.data_type),
2495                        nullable,
2496                        pk_indicator
2497                    ));
2498                }
2499
2500                html.push_str("</div></div>");
2501            }
2502
2503            Html(html)
2504        }
2505        Err(e) => Html(format!(
2506            r#"<div class="error-message">Failed to load schema: {}</div>"#,
2507            html_escape(&e.to_string())
2508        )),
2509    }
2510}
2511
2512/// Build the router with all routes.
2513fn build_router(state: DashboardServer) -> Router {
2514    // Configure CORS for development
2515    let cors = CorsLayer::new()
2516        .allow_origin(Any)
2517        .allow_methods(Any)
2518        .allow_headers(Any);
2519
2520    Router::new()
2521        // Page routes
2522        .route("/", get(root))
2523        .route("/workers", get(workers_page))
2524        .route("/tasks", get(tasks_page))
2525        .route(
2526            "/tasks/{task_id}",
2527            get(task_detail_page)
2528                .post(task_update_handler)
2529                .delete(task_delete_handler),
2530        )
2531        .route("/activity", get(activity_page))
2532        .route("/file-marks", get(file_marks_page))
2533        .route("/metrics", get(metrics_page))
2534        .route("/graph", get(graph_page))
2535        .route("/sql", get(sql_query_page))
2536        // htmx fragment routes (for periodic refresh)
2537        .route("/api/stats", get(api_stats))
2538        .route("/api/tasks/recent", get(api_recent_tasks))
2539        .route("/api/tasks/list", get(api_tasks_list))
2540        .route("/api/tasks/search", get(api_tasks_search))
2541        .route("/api/tasks/phases", get(api_tasks_phases))
2542        .route("/api/states/config", get(api_states_config))
2543        .route("/api/tasks/bulk", post(api_tasks_bulk))
2544        .route("/api/workers/active", get(api_active_workers))
2545        .route("/api/workers/list", get(api_workers_list))
2546        .route("/api/workers/{worker_id}/details", get(api_worker_details))
2547        .route(
2548            "/api/workers/{worker_id}/disconnect",
2549            post(api_worker_disconnect),
2550        )
2551        .route("/api/workers/cleanup", post(api_workers_cleanup))
2552        .route("/api/activity/stats", get(api_activity_stats))
2553        .route("/api/activity/list", get(api_activity_list))
2554        .route("/api/file-marks/stats", get(api_file_marks_stats))
2555        .route("/api/file-marks/list", get(api_file_marks_list))
2556        .route(
2557            "/api/file-marks/force-unmark",
2558            post(api_file_marks_force_unmark),
2559        )
2560        // Metrics routes
2561        .route("/api/metrics/overview", get(api_metrics_overview))
2562        .route("/api/metrics/distribution", get(api_metrics_distribution))
2563        .route("/api/metrics/velocity", get(api_metrics_velocity))
2564        .route(
2565            "/api/metrics/time-in-status",
2566            get(api_metrics_time_in_status),
2567        )
2568        .route("/api/metrics/cost-by-agent", get(api_metrics_cost_by_agent))
2569        .route("/api/metrics/custom", get(api_metrics_custom))
2570        // Graph routes
2571        .route("/api/graph/mermaid", get(api_graph_mermaid))
2572        .route("/api/graph/stats", get(api_graph_stats))
2573        // SQL query routes
2574        .route("/api/sql/execute", post(api_sql_execute))
2575        .route("/api/sql/schema", get(api_sql_schema))
2576        // API routes
2577        .route("/api", get(api_root))
2578        .route("/api/health", get(health))
2579        // Add middleware
2580        .layer(cors)
2581        .layer(TraceLayer::new_for_http())
2582        .with_state(state)
2583}
2584
2585/// Status of the dashboard server.
2586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2587pub enum DashboardStatus {
2588    /// Dashboard is running and serving requests.
2589    Running,
2590    /// Dashboard failed to start, retrying in background.
2591    Retrying,
2592    /// Dashboard has been shut down.
2593    Stopped,
2594}
2595
2596/// Handle for managing the dashboard server lifecycle.
2597pub struct DashboardHandle {
2598    /// Channel to signal shutdown.
2599    shutdown_tx: Option<oneshot::Sender<()>>,
2600    /// Receiver for status updates.
2601    status_rx: watch::Receiver<DashboardStatus>,
2602}
2603
2604impl DashboardHandle {
2605    /// Get the current status of the dashboard.
2606    pub fn status(&self) -> DashboardStatus {
2607        *self.status_rx.borrow()
2608    }
2609
2610    /// Trigger shutdown of the dashboard server.
2611    pub fn shutdown(mut self) {
2612        if let Some(tx) = self.shutdown_tx.take() {
2613            let _ = tx.send(());
2614        }
2615    }
2616}
2617
2618/// Start the HTTP server on the specified port.
2619///
2620/// Returns a oneshot sender that can be used to signal shutdown,
2621/// and the actual address the server is bound to.
2622pub async fn start_server(
2623    db: Arc<Database>,
2624    port: u16,
2625    states_config: Arc<StatesConfig>,
2626) -> anyhow::Result<(oneshot::Sender<()>, SocketAddr)> {
2627    let state = DashboardServer::new(db, port, states_config);
2628    let app = build_router(state);
2629
2630    let addr = SocketAddr::from(([127, 0, 0, 1], port));
2631    let listener = tokio::net::TcpListener::bind(addr).await?;
2632    let bound_addr = listener.local_addr()?;
2633
2634    info!("Dashboard server listening on http://{}", bound_addr);
2635
2636    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
2637
2638    tokio::spawn(async move {
2639        if let Err(e) = axum::serve(listener, app)
2640            .with_graceful_shutdown(async {
2641                let _ = shutdown_rx.await;
2642                info!("Dashboard server shutting down");
2643            })
2644            .await
2645        {
2646            // Log error but don't crash - the main MCP server continues
2647            tracing::error!("Dashboard server error: {}", e);
2648        }
2649    });
2650
2651    Ok((shutdown_tx, bound_addr))
2652}
2653
2654/// Compute jittered delay for retry.
2655/// Uses system time nanoseconds for simple jitter without requiring rand crate.
2656fn compute_jittered_delay(base_ms: u64, jitter_ms: u64) -> Duration {
2657    use std::time::SystemTime;
2658
2659    let nanos = SystemTime::now()
2660        .duration_since(SystemTime::UNIX_EPOCH)
2661        .map(|d| d.subsec_nanos())
2662        .unwrap_or(0);
2663
2664    // Map nanos to range [-jitter_ms, +jitter_ms]
2665    let jitter_range = (jitter_ms * 2) as i64;
2666    let jitter = if jitter_range > 0 {
2667        (nanos as i64 % jitter_range) - (jitter_ms as i64)
2668    } else {
2669        0
2670    };
2671
2672    let delay_ms = (base_ms as i64 + jitter).max(1000) as u64; // At least 1 second
2673    Duration::from_millis(delay_ms)
2674}
2675
2676/// Start the HTTP server with automatic retry on failure.
2677///
2678/// This function never fails - if the port is in use, it will retry in the background
2679/// with exponential backoff. Returns a handle to monitor and control the dashboard.
2680///
2681/// # Arguments
2682/// * `db` - Database handle
2683/// * `ui_config` - UI configuration including port and retry settings
2684/// * `states_config` - States configuration for the dashboard
2685pub fn start_server_with_retry(
2686    db: Arc<Database>,
2687    ui_config: &UiConfig,
2688    states_config: Arc<StatesConfig>,
2689) -> DashboardHandle {
2690    let port = ui_config.port;
2691    let retry_initial_ms = ui_config.retry_initial_ms;
2692    let retry_jitter_ms = ui_config.retry_jitter_ms;
2693    let retry_max_ms = ui_config.retry_max_ms;
2694    let retry_multiplier = ui_config.retry_multiplier;
2695
2696    let (status_tx, status_rx) = watch::channel(DashboardStatus::Retrying);
2697    let (handle_shutdown_tx, mut handle_shutdown_rx) = oneshot::channel::<()>();
2698
2699    let db_clone = Arc::clone(&db);
2700    let states_config_clone = Arc::clone(&states_config);
2701
2702    tokio::spawn(async move {
2703        let mut current_delay_ms = retry_initial_ms;
2704        let mut server_shutdown_tx: Option<oneshot::Sender<()>> = None;
2705
2706        loop {
2707            // Check if we've been asked to shut down
2708            match handle_shutdown_rx.try_recv() {
2709                Ok(()) | Err(oneshot::error::TryRecvError::Closed) => {
2710                    info!("Dashboard retry loop shutting down");
2711                    if let Some(tx) = server_shutdown_tx.take() {
2712                        let _ = tx.send(());
2713                    }
2714                    let _ = status_tx.send(DashboardStatus::Stopped);
2715                    break;
2716                }
2717                Err(oneshot::error::TryRecvError::Empty) => {}
2718            }
2719
2720            // Try to start the server
2721            match start_server(
2722                Arc::clone(&db_clone),
2723                port,
2724                Arc::clone(&states_config_clone),
2725            )
2726            .await
2727            {
2728                Ok((shutdown_tx, bound_addr)) => {
2729                    info!("Dashboard available at http://{}", bound_addr);
2730                    let _ = status_tx.send(DashboardStatus::Running);
2731                    server_shutdown_tx = Some(shutdown_tx);
2732
2733                    // Wait for shutdown signal
2734                    let _ = handle_shutdown_rx.await;
2735                    info!("Dashboard handle shutdown received");
2736                    if let Some(tx) = server_shutdown_tx.take() {
2737                        let _ = tx.send(());
2738                    }
2739                    let _ = status_tx.send(DashboardStatus::Stopped);
2740                    break;
2741                }
2742                Err(e) => {
2743                    warn!(
2744                        "Failed to start dashboard on port {}: {}. Retrying in {:.1}s...",
2745                        port,
2746                        e,
2747                        current_delay_ms as f64 / 1000.0
2748                    );
2749                    let _ = status_tx.send(DashboardStatus::Retrying);
2750
2751                    // Wait with jitter
2752                    let delay = compute_jittered_delay(current_delay_ms, retry_jitter_ms);
2753                    tokio::time::sleep(delay).await;
2754
2755                    // Exponential backoff, capped at max
2756                    current_delay_ms =
2757                        ((current_delay_ms as f64 * retry_multiplier) as u64).min(retry_max_ms);
2758                }
2759            }
2760        }
2761    });
2762
2763    DashboardHandle {
2764        shutdown_tx: Some(handle_shutdown_tx),
2765        status_rx,
2766    }
2767}
2768
2769#[cfg(test)]
2770mod tests {
2771    use super::*;
2772
2773    #[test]
2774    fn test_health_response_serialization() {
2775        let response = HealthResponse {
2776            status: "healthy",
2777            version: "0.1.0",
2778        };
2779        let json = serde_json::to_string(&response).unwrap();
2780        assert!(json.contains("healthy"));
2781        assert!(json.contains("0.1.0"));
2782    }
2783}