parent
45c51730ec
commit
f97e075ee6
6 changed files with 275 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’erreur 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">
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue