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