parent
45c51730ec
commit
f97e075ee6
6 changed files with 275 additions and 4 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::credential::TuleapCredentials;
|
use crate::models::credential::TuleapCredentials;
|
||||||
use crate::models::module::{ProjectModule, MODULE_TULEAP_AUTO_RESOLVE};
|
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::models::tracker::WatchedTracker;
|
||||||
use crate::services::tuleap_client::TuleapClient;
|
use crate::services::tuleap_client::TuleapClient;
|
||||||
use crate::services::{crypto, filter_engine, notifier};
|
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)?;
|
let tickets = ProcessedTicket::list_by_project(&db, &project_id)?;
|
||||||
Ok(tickets)
|
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::tracker::list_processed_tickets,
|
||||||
commands::poller::manual_poll,
|
commands::poller::manual_poll,
|
||||||
commands::poller::get_queue_status,
|
commands::poller::get_queue_status,
|
||||||
|
commands::poller::get_project_throughput,
|
||||||
commands::notification::list_notifications,
|
commands::notification::list_notifications,
|
||||||
commands::notification::mark_notification_read,
|
commands::notification::mark_notification_read,
|
||||||
commands::notification::mark_all_notifications_read,
|
commands::notification::mark_all_notifications_read,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ pub struct ProcessedTicket {
|
||||||
pub processed_at: Option<String>,
|
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> {
|
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
|
||||||
Ok(ProcessedTicket {
|
Ok(ProcessedTicket {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
|
@ -124,6 +132,62 @@ impl ProcessedTicket {
|
||||||
rows.collect()
|
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> {
|
pub fn get_by_id(conn: &Connection, id: &str) -> Result<ProcessedTicket> {
|
||||||
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
|
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
|
||||||
conn.query_row(&sql, params![id], from_row)
|
conn.query_row(&sql, params![id], from_row)
|
||||||
|
|
@ -217,6 +281,41 @@ mod tests {
|
||||||
(conn, tracker.id)
|
(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]
|
#[test]
|
||||||
fn test_insert_if_new_creates_ticket() {
|
fn test_insert_if_new_creates_ticket() {
|
||||||
let (conn, tracker_id) = setup();
|
let (conn, tracker_id) = setup();
|
||||||
|
|
@ -441,4 +540,85 @@ mod tests {
|
||||||
.expect("processed_at should be set");
|
.expect("processed_at should be set");
|
||||||
assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok());
|
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 { listen } from "@tauri-apps/api/event";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||||
import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api";
|
import {
|
||||||
import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types";
|
getProject,
|
||||||
|
deleteProject,
|
||||||
|
listTrackers,
|
||||||
|
listProcessedTickets,
|
||||||
|
getProjectThroughput,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
Project,
|
||||||
|
WatchedTracker,
|
||||||
|
ProcessedTicket,
|
||||||
|
ProjectThroughputStats,
|
||||||
|
} from "../../lib/types";
|
||||||
import TrackerList from "../trackers/TrackerList";
|
import TrackerList from "../trackers/TrackerList";
|
||||||
import ConfirmModal from "../ui/ConfirmModal";
|
import ConfirmModal from "../ui/ConfirmModal";
|
||||||
|
|
||||||
|
|
@ -39,6 +50,7 @@ export default function ProjectDashboard() {
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
|
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
|
||||||
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
||||||
|
const [throughput, setThroughput] = useState<ProjectThroughputStats | null>(null);
|
||||||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||||||
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
|
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
|
||||||
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
|
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
|
||||||
|
|
@ -57,14 +69,16 @@ export default function ProjectDashboard() {
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const [proj, trks, tkts] = await Promise.all([
|
const [proj, trks, tkts, stats] = await Promise.all([
|
||||||
getProject(projectId),
|
getProject(projectId),
|
||||||
listTrackers(projectId),
|
listTrackers(projectId),
|
||||||
listProcessedTickets(projectId),
|
listProcessedTickets(projectId),
|
||||||
|
getProjectThroughput(projectId),
|
||||||
]);
|
]);
|
||||||
setProject(proj);
|
setProject(proj);
|
||||||
setTrackers(trks);
|
setTrackers(trks);
|
||||||
setTickets(tkts);
|
setTickets(tkts);
|
||||||
|
setThroughput(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (!project) {
|
||||||
return <div className="p-8 text-gray-400">Loading...</div>;
|
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 recentTickets = tickets.slice(-10).reverse();
|
||||||
const activePollList = Object.entries(activePolls);
|
const activePollList = Object.entries(activePolls);
|
||||||
const activeAgentList = Object.values(activeAgents);
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
@ -288,6 +323,36 @@ export default function ProjectDashboard() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="mt-8">
|
||||||
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
|
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
WatchedTracker,
|
WatchedTracker,
|
||||||
TrackerField,
|
TrackerField,
|
||||||
ProcessedTicket,
|
ProcessedTicket,
|
||||||
|
ProjectThroughputStats,
|
||||||
Worktree,
|
Worktree,
|
||||||
TicketResult,
|
TicketResult,
|
||||||
OrchaiNotification,
|
OrchaiNotification,
|
||||||
|
|
@ -166,6 +167,9 @@ export async function manualPoll(trackerId: string): Promise<ProcessedTicket[]>
|
||||||
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
|
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
|
||||||
return invoke("get_queue_status", { projectId });
|
return invoke("get_queue_status", { projectId });
|
||||||
}
|
}
|
||||||
|
export async function getProjectThroughput(projectId: string): Promise<ProjectThroughputStats> {
|
||||||
|
return invoke("get_project_throughput", { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
// Orchestrator
|
// Orchestrator
|
||||||
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
|
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,13 @@ export interface ProcessedTicket {
|
||||||
processed_at: string | null;
|
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 {
|
export interface Worktree {
|
||||||
id: string;
|
id: string;
|
||||||
ticket_id: string;
|
ticket_id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue