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.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 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 let (heartbeat_text, heartbeat_class) = format_time_ago(heartbeat_age);
236
237 let registered_age = now - worker.registered_at;
239 let (registered_text, _) = format_time_ago(registered_age);
240
241 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 let worker_id_escaped = html_escape(&worker.id);
250 let worker_id_attr = worker.id.replace('"', """).replace('\'', "'");
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">▶</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
286async 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 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 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 let worker_id_attr = worker_id.replace('"', """).replace('\'', "'");
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#[derive(Debug, serde::Deserialize)]
378struct DisconnectForm {
379 final_status: Option<String>,
380}
381
382async 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 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 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
413async fn api_workers_cleanup(State(state): State<DashboardServer>) -> Html<String> {
415 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
440async fn tasks_page() -> Html<&'static str> {
442 Html(templates::TASKS_TEMPLATE)
443}
444
445async fn activity_page() -> Html<&'static str> {
447 Html(templates::ACTIVITY_TEMPLATE)
448}
449
450#[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
461async 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
500async 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" => "✓",
536 "working" => "▶",
537 "pending" => "●",
538 "failed" => "✗",
539 "cancelled" => "✘",
540 "assigned" => "➤",
541 _ => "→",
542 };
543 (icon, "event-type-transition", status)
544 }
545 crate::db::dashboard::ActivityEventType::FileClaim => {
546 ("🔒", "event-type-claim", "claimed")
547 }
548 crate::db::dashboard::ActivityEventType::FileRelease => {
549 ("🔓", "event-type-release", "released")
550 }
551 };
552
553 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 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 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 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 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
694fn 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 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 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
721async 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 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 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 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 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 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 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 let owner_html = task
822 .worker_id
823 .as_deref()
824 .map(html_escape)
825 .unwrap_or_else(|| "-".to_string());
826
827 let title = task.title.as_str();
829 let title_display = if title.is_empty() { &task.id } else { title };
830
831 let description = task.description.as_deref().unwrap_or("");
833 let description_escaped = html_escape(description);
834
835 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 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 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#[derive(Debug, serde::Deserialize)]
920struct TaskUpdateForm {
921 status: Option<String>,
922 priority: Option<i32>,
923 tags: Option<String>,
924 description: Option<String>,
925}
926
927async fn task_update_handler(
929 State(state): State<DashboardServer>,
930 Path(task_id): Path<String>,
931 Form(form): Form<TaskUpdateForm>,
932) -> impl IntoResponse {
933 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 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
959async 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?deleted=1")
968 }
969 Err(_) => {
970 Redirect::to(&format!("/tasks/{}?msg=error:Delete+failed", task_id))
972 }
973 }
974}
975
976#[derive(Debug, serde::Deserialize)]
978struct BulkOperationRequest {
979 action: String,
980 task_ids: Vec<String>,
981 status: Option<String>,
982}
983
984#[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#[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 show_untimed: Option<String>,
1003 sort: Option<String>,
1004 page: Option<i32>,
1005 limit: Option<i32>,
1006}
1007
1008async fn api_tasks_list(
1010 State(state): State<DashboardServer>,
1011 Query(params): Query<TaskListParams>,
1012) -> Html<String> {
1013 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 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 (None, Vec::new())
1033 } else {
1034 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 let tags_html = if task.tags.is_empty() || task.tags == "[]" {
1120 String::new()
1121 } else {
1122 match serde_json::from_str::<Vec<String>>(&task.tags) {
1124 Ok(tags) => tags
1125 .iter()
1126 .take(3) .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 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#[derive(Debug, serde::Deserialize)]
1208struct TaskSearchParams {
1209 q: Option<String>,
1210 status: Option<String>,
1211 limit: Option<i32>,
1212}
1213
1214async 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 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, 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 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 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 let title_display = if result.title_snippet.is_empty() {
1388 html_escape(&result.title)
1389 } else {
1390 result
1392 .title_snippet
1393 .replace('<', "<")
1394 .replace('>', ">")
1395 .replace("<mark>", "<mark>")
1396 .replace("</mark>", "</mark>")
1397 };
1398
1399 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
1427async 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#[derive(serde::Serialize)]
1437struct StatesConfigResponse {
1438 states: Vec<String>,
1440 timed_states: Vec<String>,
1442 untimed_states: Vec<String>,
1444}
1445
1446async 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
1472async 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 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
1547fn html_escape(s: &str) -> String {
1549 s.replace('&', "&")
1550 .replace('<', "<")
1551 .replace('>', ">")
1552 .replace('"', """)
1553 .replace('\'', "'")
1554}
1555
1556async fn health() -> impl IntoResponse {
1558 Json(HealthResponse {
1559 status: "healthy",
1560 version: env!("CARGO_PKG_VERSION"),
1561 })
1562}
1563
1564async 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
1576async fn file_marks_page() -> Html<&'static str> {
1578 Html(templates::FILE_MARKS_TEMPLATE)
1579}
1580
1581async fn metrics_page() -> Html<&'static str> {
1583 Html(templates::METRICS_TEMPLATE)
1584}
1585
1586async 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 (>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
1630async 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 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#[derive(Debug, serde::Deserialize)]
1720struct ForceUnmarkForm {
1721 file_path: String,
1722}
1723
1724async 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 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
1745fn 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
1780async 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
1825async 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 let mut bar_html = String::from(r#"<div class="status-bar">"#);
1840
1841 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 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#[derive(Debug, serde::Deserialize)]
1889struct VelocityParams {
1890 period: Option<String>,
1891}
1892
1893async 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 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 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
1961async 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
2012async 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
2063async 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 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
2093async fn graph_page() -> Html<&'static str> {
2097 Html(templates::DEP_GRAPH_TEMPLATE)
2098}
2099
2100#[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#[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
2119fn 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 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 diagram.push_str(" %% Nodes\n");
2141 for node in &graph.nodes {
2142 let safe_id = sanitize_mermaid_id(&node.id);
2144
2145 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 let escaped_label = display_label
2154 .replace('"', "'")
2155 .replace('<', "<")
2156 .replace('>', ">");
2157
2158 diagram.push_str(&format!(" {}[\"{}\"]\n", safe_id, escaped_label));
2159 }
2160
2161 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 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
2196fn 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
2209fn 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
2218async 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
2247async 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
2290async fn sql_query_page() -> Html<&'static str> {
2292 Html(templates::SQL_QUERY_TEMPLATE)
2293}
2294
2295#[derive(Debug, serde::Deserialize)]
2297struct SqlQueryForm {
2298 sql: String,
2299 limit: Option<i32>,
2300}
2301
2302async fn api_sql_execute(
2304 State(state): State<DashboardServer>,
2305 Form(form): Form<SqlQueryForm>,
2306) -> Html<String> {
2307 use std::time::Duration;
2308
2309 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 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 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 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 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 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 html.push_str(r#"<div class="results-container"><table>"#);
2421
2422 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 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
2460async 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 html.push_str(&format!(
2471 r#"<div class="schema-table">
2472 <div class="schema-table-name" onclick="toggleSchemaTable(this)">
2473 <span class="toggle-icon">▼</span> {}
2474 </div>
2475 <div class="schema-columns">"#,
2476 html_escape(&table.name)
2477 ));
2478
2479 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
2512fn build_router(state: DashboardServer) -> Router {
2514 let cors = CorsLayer::new()
2516 .allow_origin(Any)
2517 .allow_methods(Any)
2518 .allow_headers(Any);
2519
2520 Router::new()
2521 .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 .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 .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 .route("/api/graph/mermaid", get(api_graph_mermaid))
2572 .route("/api/graph/stats", get(api_graph_stats))
2573 .route("/api/sql/execute", post(api_sql_execute))
2575 .route("/api/sql/schema", get(api_sql_schema))
2576 .route("/api", get(api_root))
2578 .route("/api/health", get(health))
2579 .layer(cors)
2581 .layer(TraceLayer::new_for_http())
2582 .with_state(state)
2583}
2584
2585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2587pub enum DashboardStatus {
2588 Running,
2590 Retrying,
2592 Stopped,
2594}
2595
2596pub struct DashboardHandle {
2598 shutdown_tx: Option<oneshot::Sender<()>>,
2600 status_rx: watch::Receiver<DashboardStatus>,
2602}
2603
2604impl DashboardHandle {
2605 pub fn status(&self) -> DashboardStatus {
2607 *self.status_rx.borrow()
2608 }
2609
2610 pub fn shutdown(mut self) {
2612 if let Some(tx) = self.shutdown_tx.take() {
2613 let _ = tx.send(());
2614 }
2615 }
2616}
2617
2618pub 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 tracing::error!("Dashboard server error: {}", e);
2648 }
2649 });
2650
2651 Ok((shutdown_tx, bound_addr))
2652}
2653
2654fn 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 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; Duration::from_millis(delay_ms)
2674}
2675
2676pub 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 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 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 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 let delay = compute_jittered_delay(current_delay_ms, retry_jitter_ms);
2753 tokio::time::sleep(delay).await;
2754
2755 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}