From f97e075ee6f63e951bd61d76d509ccb32d2f773c Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 17:44:40 +0200 Subject: [PATCH] feat: add project throughput dashboard metrics closes #7 --- src-tauri/src/commands/poller.rs | 16 +- src-tauri/src/lib.rs | 1 + src-tauri/src/models/ticket.rs | 180 +++++++++++++++++++ src/components/projects/ProjectDashboard.tsx | 71 +++++++- src/lib/api.ts | 4 + src/lib/types.ts | 7 + 6 files changed, 275 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index ca46066..1932930 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -1,7 +1,7 @@ use crate::error::AppError; use crate::models::credential::TuleapCredentials; use crate::models::module::{ProjectModule, MODULE_TULEAP_AUTO_RESOLVE}; -use crate::models::ticket::ProcessedTicket; +use crate::models::ticket::{ProcessedTicket, ProjectThroughputStats}; use crate::models::tracker::WatchedTracker; use crate::services::tuleap_client::TuleapClient; use crate::services::{crypto, filter_engine, notifier}; @@ -164,3 +164,17 @@ pub fn get_queue_status( let tickets = ProcessedTicket::list_by_project(&db, &project_id)?; Ok(tickets) } + +#[tauri::command] +pub fn get_project_throughput( + state: State<'_, AppState>, + project_id: String, +) -> Result { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let stats = ProcessedTicket::get_project_throughput_stats(&db, &project_id)?; + Ok(stats) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c631510..20d0830 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ pub fn run() { commands::tracker::list_processed_tickets, commands::poller::manual_poll, commands::poller::get_queue_status, + commands::poller::get_project_throughput, commands::notification::list_notifications, commands::notification::mark_notification_read, commands::notification::mark_all_notifications_read, diff --git a/src-tauri/src/models/ticket.rs b/src-tauri/src/models/ticket.rs index 8ae053f..fae1ac4 100644 --- a/src-tauri/src/models/ticket.rs +++ b/src-tauri/src/models/ticket.rs @@ -18,6 +18,14 @@ pub struct ProcessedTicket { pub processed_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectThroughputStats { + pub backlog_count: i64, + pub done_last_24h: i64, + pub error_last_24h: i64, + pub avg_lead_time_seconds: Option, +} + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(ProcessedTicket { id: row.get(0)?, @@ -124,6 +132,62 @@ impl ProcessedTicket { rows.collect() } + pub fn get_project_throughput_stats( + conn: &Connection, + project_id: &str, + ) -> Result { + let window_start = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339(); + + conn.query_row( + "SELECT + COALESCE(SUM( + CASE + WHEN pt.status NOT IN ('Done', 'Error', 'Cancelled') THEN 1 + ELSE 0 + END + ), 0) AS backlog_count, + COALESCE(SUM( + CASE + WHEN pt.status = 'Done' + AND pt.processed_at IS NOT NULL + AND julianday(pt.processed_at) >= julianday(?2) + THEN 1 + ELSE 0 + END + ), 0) AS done_last_24h, + COALESCE(SUM( + CASE + WHEN pt.status = 'Error' + AND pt.processed_at IS NOT NULL + AND julianday(pt.processed_at) >= julianday(?2) + THEN 1 + ELSE 0 + END + ), 0) AS error_last_24h, + AVG( + CASE + WHEN pt.status IN ('Done', 'Error') + AND pt.processed_at IS NOT NULL + AND julianday(pt.processed_at) >= julianday(?2) + THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0 + ELSE NULL + END + ) AS avg_lead_time_seconds + FROM processed_tickets pt + JOIN watched_trackers wt ON wt.id = pt.tracker_id + WHERE wt.project_id = ?1", + params![project_id, window_start], + |row| { + Ok(ProjectThroughputStats { + backlog_count: row.get(0)?, + done_last_24h: row.get(1)?, + error_last_24h: row.get(2)?, + avg_lead_time_seconds: row.get(3)?, + }) + }, + ) + } + pub fn get_by_id(conn: &Connection, id: &str) -> Result { let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS); conn.query_row(&sql, params![id], from_row) @@ -217,6 +281,41 @@ mod tests { (conn, tracker.id) } + fn project_id_for_tracker(conn: &Connection, tracker_id: &str) -> String { + conn.query_row( + "SELECT project_id FROM watched_trackers WHERE id = ?1", + params![tracker_id], + |row| row.get(0), + ) + .unwrap() + } + + fn insert_ticket_with_timestamps( + conn: &Connection, + tracker_id: &str, + artifact_id: i32, + status: &str, + detected_at: &str, + processed_at: Option<&str>, + ) { + conn.execute( + "INSERT INTO processed_tickets \ + (id, tracker_id, artifact_id, artifact_title, artifact_data, status, detected_at, processed_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + Uuid::new_v4().to_string(), + tracker_id, + artifact_id, + format!("Ticket {}", artifact_id), + "{}", + status, + detected_at, + processed_at + ], + ) + .unwrap(); + } + #[test] fn test_insert_if_new_creates_ticket() { let (conn, tracker_id) = setup(); @@ -441,4 +540,85 @@ mod tests { .expect("processed_at should be set"); assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok()); } + + #[test] + fn test_get_project_throughput_stats() { + let (conn, tracker_id) = setup(); + let project_id = project_id_for_tracker(&conn, &tracker_id); + let now = chrono::Utc::now(); + let pending_detected = (now - chrono::Duration::hours(2)).to_rfc3339(); + let developing_detected = (now - chrono::Duration::hours(3)).to_rfc3339(); + let done_detected = (now - chrono::Duration::hours(4)).to_rfc3339(); + let done_processed = (now - chrono::Duration::hours(2)).to_rfc3339(); + let error_detected = (now - chrono::Duration::hours(7)).to_rfc3339(); + let error_processed = (now - chrono::Duration::hours(3)).to_rfc3339(); + let old_done_detected = (now - chrono::Duration::hours(40)).to_rfc3339(); + let old_done_processed = (now - chrono::Duration::hours(35)).to_rfc3339(); + let cancelled_detected = (now - chrono::Duration::hours(1)).to_rfc3339(); + + insert_ticket_with_timestamps(&conn, &tracker_id, 1001, "Pending", &pending_detected, None); + insert_ticket_with_timestamps( + &conn, + &tracker_id, + 1002, + "Developing", + &developing_detected, + None, + ); + insert_ticket_with_timestamps( + &conn, + &tracker_id, + 1003, + "Done", + &done_detected, + Some(&done_processed), + ); + insert_ticket_with_timestamps( + &conn, + &tracker_id, + 1004, + "Error", + &error_detected, + Some(&error_processed), + ); + insert_ticket_with_timestamps( + &conn, + &tracker_id, + 1005, + "Done", + &old_done_detected, + Some(&old_done_processed), + ); + insert_ticket_with_timestamps( + &conn, + &tracker_id, + 1006, + "Cancelled", + &cancelled_detected, + None, + ); + + let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap(); + + assert_eq!(stats.backlog_count, 2); + assert_eq!(stats.done_last_24h, 1); + assert_eq!(stats.error_last_24h, 1); + + let avg = stats + .avg_lead_time_seconds + .expect("avg lead time should be available"); + assert!((avg - 10800.0).abs() < 1.0); + } + + #[test] + fn test_get_project_throughput_stats_empty() { + let (conn, tracker_id) = setup(); + let project_id = project_id_for_tracker(&conn, &tracker_id); + + let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap(); + assert_eq!(stats.backlog_count, 0); + assert_eq!(stats.done_last_24h, 0); + assert_eq!(stats.error_last_24h, 0); + assert!(stats.avg_lead_time_seconds.is_none()); + } } diff --git a/src/components/projects/ProjectDashboard.tsx b/src/components/projects/ProjectDashboard.tsx index 74e17ce..e93e4b2 100644 --- a/src/components/projects/ProjectDashboard.tsx +++ b/src/components/projects/ProjectDashboard.tsx @@ -1,8 +1,19 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect, useState } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; -import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api"; -import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types"; +import { + getProject, + deleteProject, + listTrackers, + listProcessedTickets, + getProjectThroughput, +} from "../../lib/api"; +import type { + Project, + WatchedTracker, + ProcessedTicket, + ProjectThroughputStats, +} from "../../lib/types"; import TrackerList from "../trackers/TrackerList"; import ConfirmModal from "../ui/ConfirmModal"; @@ -39,6 +50,7 @@ export default function ProjectDashboard() { const [project, setProject] = useState(null); const [trackers, setTrackers] = useState([]); const [tickets, setTickets] = useState([]); + const [throughput, setThroughput] = useState(null); const [activity, setActivity] = useState([]); const [activePolls, setActivePolls] = useState>({}); const [activeAgents, setActiveAgents] = useState>({}); @@ -57,14 +69,16 @@ export default function ProjectDashboard() { async function loadData() { if (!projectId) return; - const [proj, trks, tkts] = await Promise.all([ + const [proj, trks, tkts, stats] = await Promise.all([ getProject(projectId), listTrackers(projectId), listProcessedTickets(projectId), + getProjectThroughput(projectId), ]); setProject(proj); setTrackers(trks); setTickets(tkts); + setThroughput(stats); } useEffect(() => { @@ -239,6 +253,23 @@ export default function ProjectDashboard() { } } + function formatLeadTime(seconds: number | null): string { + if (seconds === null || Number.isNaN(seconds)) { + return "—"; + } + + if (seconds < 60) { + return `${Math.round(seconds)} s`; + } + if (seconds < 3600) { + return `${Math.round(seconds / 60)} min`; + } + if (seconds < 86400) { + return `${(seconds / 3600).toFixed(1)} h`; + } + return `${(seconds / 86400).toFixed(1)} j`; + } + if (!project) { return
Loading...
; } @@ -246,6 +277,10 @@ export default function ProjectDashboard() { const recentTickets = tickets.slice(-10).reverse(); const activePollList = Object.entries(activePolls); const activeAgentList = Object.values(activeAgents); + const done24h = throughput?.done_last_24h ?? 0; + const error24h = throughput?.error_last_24h ?? 0; + const resolved24h = done24h + error24h; + const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—"; return (
@@ -288,6 +323,36 @@ export default function ProjectDashboard() {
+
+

Throughput & santé (24h)

+
+
+
Backlog
+
+ {throughput?.backlog_count ?? 0} +
+
Tickets encore en cours.
+
+
+
Done
+
{done24h}
+
Tickets finalisés en 24h.
+
+
+
Error
+
{error24h}
+
Taux d’erreur 24h: {errorRate24h}
+
+
+
Lead time moyen
+
+ {formatLeadTime(throughput?.avg_lead_time_seconds ?? null)} +
+
Sur les tickets clos en 24h.
+
+
+
+

Orchestrateur IA

diff --git a/src/lib/api.ts b/src/lib/api.ts index 8dc342c..c7d479d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,7 @@ import type { WatchedTracker, TrackerField, ProcessedTicket, + ProjectThroughputStats, Worktree, TicketResult, OrchaiNotification, @@ -166,6 +167,9 @@ export async function manualPoll(trackerId: string): Promise export async function getQueueStatus(projectId: string): Promise { return invoke("get_queue_status", { projectId }); } +export async function getProjectThroughput(projectId: string): Promise { + return invoke("get_project_throughput", { projectId }); +} // Orchestrator export async function getTicketResult(ticketId: string): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index e79af04..6327eff 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -79,6 +79,13 @@ export interface ProcessedTicket { processed_at: string | null; } +export interface ProjectThroughputStats { + backlog_count: number; + done_last_24h: number; + error_last_24h: number; + avg_lead_time_seconds: number | null; +} + export interface Worktree { id: string; ticket_id: string;