feat: add project throughput dashboard metrics

closes #7
This commit is contained in:
thibaud-lclr 2026-04-16 17:44:40 +02:00
parent 45c51730ec
commit f97e075ee6
6 changed files with 275 additions and 4 deletions

View file

@ -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<ProjectThroughputStats, AppError> {
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)
}

View file

@ -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,

View file

@ -18,6 +18,14 @@ pub struct ProcessedTicket {
pub processed_at: Option<String>,
}
#[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<f64>,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
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<ProjectThroughputStats> {
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<ProcessedTicket> {
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());
}
}

View file

@ -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<Project | null>(null);
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
const [throughput, setThroughput] = useState<ProjectThroughputStats | null>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
@ -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 <div className="p-8 text-gray-400">Loading...</div>;
}
@ -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 (
<div className="p-8">
@ -288,6 +323,36 @@ export default function ProjectDashboard() {
</div>
</div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Throughput & santé (24h)</h3>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="text-xs uppercase tracking-wide text-gray-500">Backlog</div>
<div className="mt-2 text-2xl font-semibold text-gray-900">
{throughput?.backlog_count ?? 0}
</div>
<div className="mt-1 text-xs text-gray-500">Tickets encore en cours.</div>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="text-xs uppercase tracking-wide text-green-700">Done</div>
<div className="mt-2 text-2xl font-semibold text-green-900">{done24h}</div>
<div className="mt-1 text-xs text-green-700">Tickets finalisés en 24h.</div>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<div className="text-xs uppercase tracking-wide text-red-700">Error</div>
<div className="mt-2 text-2xl font-semibold text-red-900">{error24h}</div>
<div className="mt-1 text-xs text-red-700">Taux derreur 24h: {errorRate24h}</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="text-xs uppercase tracking-wide text-blue-700">Lead time moyen</div>
<div className="mt-2 text-2xl font-semibold text-blue-900">
{formatLeadTime(throughput?.avg_lead_time_seconds ?? null)}
</div>
<div className="mt-1 text-xs text-blue-700">Sur les tickets clos en 24h.</div>
</div>
</div>
</div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
<div className="grid gap-3 md:grid-cols-3">

View file

@ -9,6 +9,7 @@ import type {
WatchedTracker,
TrackerField,
ProcessedTicket,
ProjectThroughputStats,
Worktree,
TicketResult,
OrchaiNotification,
@ -166,6 +167,9 @@ export async function manualPoll(trackerId: string): Promise<ProcessedTicket[]>
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
return invoke("get_queue_status", { projectId });
}
export async function getProjectThroughput(projectId: string): Promise<ProjectThroughputStats> {
return invoke("get_project_throughput", { projectId });
}
// Orchestrator
export async function getTicketResult(ticketId: string): Promise<TicketResult> {

View file

@ -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;