diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index 6290e3b..ee07b9e 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -2,14 +2,15 @@ use crate::error::AppError; use crate::models::credential::TuleapCredentials; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; -use crate::services::{crypto, filter_engine}; +use crate::services::{crypto, filter_engine, notifier}; use crate::services::tuleap_client::TuleapClient; use crate::AppState; -use tauri::State; +use tauri::{Emitter, State}; #[tauri::command] pub async fn manual_poll( state: State<'_, AppState>, + app_handle: tauri::AppHandle, tracker_id: String, ) -> Result, AppError> { let (tracker, client) = { @@ -36,10 +37,32 @@ pub async fn manual_poll( (tracker, client) }; // lock dropped here - let artifacts = client - .get_artifacts(tracker.tracker_id) - .await - .map_err(AppError::from)?; + let _ = app_handle.emit( + "polling-started", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "manual", + }), + ); + + let artifacts = match client.get_artifacts(tracker.tracker_id).await { + Ok(a) => a, + Err(e) => { + let _ = app_handle.emit( + "polling-error", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "manual", + "error": e, + }), + ); + return Err(AppError::from(e)); + } + }; let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters); @@ -80,6 +103,40 @@ pub async fn manual_poll( WatchedTracker::update_last_polled(&db, &tracker.id)?; } + if !newly_inserted.is_empty() { + let _ = app_handle.emit( + "new-tickets-detected", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "count": newly_inserted.len(), + }), + ); + + for ticket in &newly_inserted { + notifier::notify_new_ticket( + &state.db, + &app_handle, + &tracker.project_id, + &ticket.id, + ticket.artifact_id, + &ticket.artifact_title, + ); + } + } + + let _ = app_handle.emit( + "polling-finished", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "manual", + "new_tickets_count": newly_inserted.len(), + }), + ); + Ok(newly_inserted) } diff --git a/src-tauri/src/commands/tracker.rs b/src-tauri/src/commands/tracker.rs index 3784062..a1352fd 100644 --- a/src-tauri/src/commands/tracker.rs +++ b/src-tauri/src/commands/tracker.rs @@ -1,7 +1,7 @@ use crate::error::AppError; use crate::models::credential::TuleapCredentials; use crate::models::ticket::ProcessedTicket; -use crate::models::tracker::{AgentConfig, FilterGroup, WatchedTracker}; +use crate::models::tracker::{AgentConfig, FilterGroup, TrackerUpdate, WatchedTracker}; use crate::services::crypto; use crate::services::tuleap_client::TuleapClient; use crate::AppState; @@ -73,17 +73,14 @@ pub fn list_trackers( pub fn update_tracker( state: State<'_, AppState>, id: String, - polling_interval: i32, - agent_config: AgentConfig, - filters: Vec, - enabled: bool, + update: TrackerUpdate, ) -> Result<(), AppError> { let db = state .db .lock() .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; - WatchedTracker::update(&db, &id, polling_interval, agent_config, filters, enabled)?; + WatchedTracker::update(&db, &id, update)?; Ok(()) } @@ -104,10 +101,18 @@ pub async fn get_tracker_fields( tracker_id: i32, ) -> Result, AppError> { let client = build_tuleap_client(&state)?; - let fields = client + let mut fields = client .get_tracker_fields(tracker_id) .await .map_err(AppError::from)?; + + fields.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase())); + for field in &mut fields { + field + .values + .sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase())); + } + Ok(fields) } diff --git a/src-tauri/src/models/tracker.rs b/src-tauri/src/models/tracker.rs index 252df15..fbe3216 100644 --- a/src-tauri/src/models/tracker.rs +++ b/src-tauri/src/models/tracker.rs @@ -36,6 +36,16 @@ pub struct WatchedTracker { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackerUpdate { + pub tracker_id: i32, + pub tracker_label: String, + pub polling_interval: i32, + pub agent_config: AgentConfig, + pub filters: Vec, + pub enabled: bool, +} + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { let agent_config_json: String = row.get(5)?; let filters_json: String = row.get(6)?; @@ -125,23 +135,24 @@ impl WatchedTracker { ) } - pub fn update( - conn: &Connection, - id: &str, - polling_interval: i32, - agent_config: AgentConfig, - filters: Vec, - enabled: bool, - ) -> Result<()> { - let agent_config_json = serde_json::to_string(&agent_config) + pub fn update(conn: &Connection, id: &str, update: TrackerUpdate) -> Result<()> { + let agent_config_json = serde_json::to_string(&update.agent_config) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let filters_json = serde_json::to_string(&filters) + let filters_json = serde_json::to_string(&update.filters) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let enabled_int = if enabled { 1i32 } else { 0i32 }; + let enabled_int = if update.enabled { 1i32 } else { 0i32 }; let affected = conn.execute( - "UPDATE watched_trackers SET polling_interval = ?1, agent_config_json = ?2, filters_json = ?3, enabled = ?4 WHERE id = ?5", - params![polling_interval, agent_config_json, filters_json, enabled_int, id], + "UPDATE watched_trackers SET tracker_id = ?1, tracker_label = ?2, polling_interval = ?3, agent_config_json = ?4, filters_json = ?5, enabled = ?6 WHERE id = ?7", + params![ + update.tracker_id, + update.tracker_label, + update.polling_interval, + agent_config_json, + filters_json, + enabled_int, + id + ], )?; if affected == 0 { @@ -258,10 +269,14 @@ mod tests { WatchedTracker::update( &conn, &t2.id, - t2.polling_interval, - sample_agent_config(), - vec![], - false, + TrackerUpdate { + tracker_id: t2.tracker_id, + tracker_label: t2.tracker_label.clone(), + polling_interval: t2.polling_interval, + agent_config: sample_agent_config(), + filters: vec![], + enabled: false, + }, ) .unwrap(); @@ -318,10 +333,23 @@ mod tests { }], }]; - WatchedTracker::update(&conn, &created.id, 60, sample_agent_config(), new_filters, false) - .expect("update should succeed"); + WatchedTracker::update( + &conn, + &created.id, + TrackerUpdate { + tracker_id: 11, + tracker_label: "Updated tracker".to_string(), + polling_interval: 60, + agent_config: sample_agent_config(), + filters: new_filters, + enabled: false, + }, + ) + .expect("update should succeed"); let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap(); + assert_eq!(updated.tracker_id, 11); + assert_eq!(updated.tracker_label, "Updated tracker"); assert_eq!(updated.polling_interval, 60); assert!(!updated.enabled); assert_eq!(updated.filters[0].conditions[0].field, "priority"); diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index 252a369..0080e6c 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -183,7 +183,9 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let _ = app_handle.emit( "ticket-processing-started", serde_json::json!({ - "ticket_id": ticket.id, + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, "step": "analyst", }), ); @@ -216,7 +218,12 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ); let _ = app_handle.emit( "ticket-processing-error", - serde_json::json!({ "ticket_id": ticket.id, "error": e }), + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "error": e + }), ); return Ok(true); } @@ -235,7 +242,11 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> .map_err(|e| format!("update_status: {}", e))?; let _ = app_handle.emit( "ticket-processing-done", - serde_json::json!({ "ticket_id": ticket.id }), + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + }), ); notifier::notify_analysis_done( db, @@ -272,7 +283,12 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ); let _ = app_handle.emit( "ticket-processing-error", - serde_json::json!({ "ticket_id": ticket.id, "error": e }), + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "error": e + }), ); } @@ -291,7 +307,9 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let _ = app_handle.emit( "ticket-processing-started", serde_json::json!({ - "ticket_id": ticket.id, + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, "step": "developer", }), ); @@ -324,7 +342,12 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ); let _ = app_handle.emit( "ticket-processing-error", - serde_json::json!({ "ticket_id": ticket.id, "error": e }), + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "error": e + }), ); return Ok(true); } @@ -340,7 +363,11 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let _ = app_handle.emit( "ticket-processing-done", - serde_json::json!({ "ticket_id": ticket.id }), + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + }), ); notifier::notify_fix_ready( db, diff --git a/src-tauri/src/services/poller.rs b/src-tauri/src/services/poller.rs index c1c907e..16480d4 100644 --- a/src-tauri/src/services/poller.rs +++ b/src-tauri/src/services/poller.rs @@ -102,11 +102,31 @@ async fn poll_single_tracker( tracker: &WatchedTracker, app_handle: &AppHandle, ) { + let _ = app_handle.emit( + "polling-started", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "scheduled", + }), + ); + // 1. Fetch artifacts let artifacts = match client.get_artifacts(tracker.tracker_id).await { Ok(a) => a, Err(e) => { eprintln!("poller: failed to fetch artifacts for tracker {}: {}", tracker.id, e); + let _ = app_handle.emit( + "polling-error", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "scheduled", + "error": e, + }), + ); return; } }; @@ -169,8 +189,9 @@ async fn poll_single_tracker( if let Err(e) = app_handle.emit( "new-tickets-detected", serde_json::json!({ - "tracker_id": tracker.id, - "tracker_label": tracker.tracker_label, + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, "count": new_tickets.len(), }), ) { @@ -188,4 +209,15 @@ async fn poll_single_tracker( ); } } + + let _ = app_handle.emit( + "polling-finished", + serde_json::json!({ + "project_id": &tracker.project_id, + "tracker_id": &tracker.id, + "tracker_label": &tracker.tracker_label, + "source": "scheduled", + "new_tickets_count": new_tickets.len(), + }), + ); } diff --git a/src/App.tsx b/src/App.tsx index a6c3698..6bda0de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/projects/ProjectDashboard.tsx b/src/components/projects/ProjectDashboard.tsx index cfff89f..d300d21 100644 --- a/src/components/projects/ProjectDashboard.tsx +++ b/src/components/projects/ProjectDashboard.tsx @@ -1,15 +1,57 @@ -import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useMemo, 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 TrackerList from "../trackers/TrackerList"; +type ActivityLevel = "info" | "success" | "error"; + +interface ActivityItem { + id: string; + level: ActivityLevel; + message: string; + at: string; +} + +interface PollingPayload { + project_id: string; + tracker_id: string; + tracker_label: string; + source: "manual" | "scheduled"; + new_tickets_count?: number; + error?: string; + count?: number; +} + +interface TicketProcessingPayload { + project_id: string; + ticket_id: string; + artifact_id: number; + step?: "analyst" | "developer"; + error?: string; +} + export default function ProjectDashboard() { const { projectId } = useParams(); const navigate = useNavigate(); const [project, setProject] = useState(null); const [trackers, setTrackers] = useState([]); const [tickets, setTickets] = useState([]); + const [activity, setActivity] = useState([]); + const [activePolls, setActivePolls] = useState>({}); + const [activeAgents, setActiveAgents] = useState>({}); + + function appendActivity(level: ActivityLevel, message: string) { + const item: ActivityItem = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + level, + message, + at: new Date().toISOString(), + }; + + setActivity((prev) => [item, ...prev].slice(0, 30)); + } async function loadData() { if (!projectId) return; @@ -27,6 +69,153 @@ export default function ProjectDashboard() { loadData(); }, [projectId]); + useEffect(() => { + if (!projectId) return; + + let unlistenFns: Array<() => void> = []; + let cancelled = false; + + async function setup() { + try { + const [unlistenPollingStarted, unlistenPollingFinished, unlistenPollingError, unlistenTicketsDetected, unlistenTicketStarted, unlistenTicketDone, unlistenTicketError] = + await Promise.all([ + listen("polling-started", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => ({ + ...prev, + [payload.tracker_id]: payload.tracker_label, + })); + appendActivity( + "info", + `Polling ${payload.source === "manual" ? "manuel" : "auto"} lancé sur "${payload.tracker_label}".` + ); + }), + listen("polling-finished", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => { + const next = { ...prev }; + delete next[payload.tracker_id]; + return next; + }); + + appendActivity( + "success", + `Polling terminé sur "${payload.tracker_label}" (${payload.new_tickets_count ?? 0} nouveau(x) ticket(s)).` + ); + void loadData(); + }), + listen("polling-error", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActivePolls((prev) => { + const next = { ...prev }; + delete next[payload.tracker_id]; + return next; + }); + + appendActivity( + "error", + `Erreur de polling sur "${payload.tracker_label}": ${payload.error ?? "erreur inconnue"}.` + ); + }), + listen("new-tickets-detected", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + appendActivity( + "success", + `${payload.count ?? 0} nouveau(x) ticket(s) détecté(s) dans "${payload.tracker_label}".` + ); + void loadData(); + }), + listen("ticket-processing-started", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActiveAgents((prev) => ({ + ...prev, + [payload.ticket_id]: payload.step ?? "processing", + })); + appendActivity( + "info", + `Agent ${payload.step ?? "processing"} lancé pour le ticket #${payload.artifact_id}.` + ); + void loadData(); + }), + listen("ticket-processing-done", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActiveAgents((prev) => { + const next = { ...prev }; + delete next[payload.ticket_id]; + return next; + }); + appendActivity( + "success", + `Pipeline agent terminé pour le ticket #${payload.artifact_id}.` + ); + void loadData(); + }), + listen("ticket-processing-error", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + + setActiveAgents((prev) => { + const next = { ...prev }; + delete next[payload.ticket_id]; + return next; + }); + appendActivity( + "error", + `Erreur agent sur le ticket #${payload.artifact_id}: ${payload.error ?? "erreur inconnue"}.` + ); + void loadData(); + }), + ]); + + if (cancelled) { + unlistenPollingStarted(); + unlistenPollingFinished(); + unlistenPollingError(); + unlistenTicketsDetected(); + unlistenTicketStarted(); + unlistenTicketDone(); + unlistenTicketError(); + return; + } + + unlistenFns = [ + unlistenPollingStarted, + unlistenPollingFinished, + unlistenPollingError, + unlistenTicketsDetected, + unlistenTicketStarted, + unlistenTicketDone, + unlistenTicketError, + ]; + } catch (err) { + appendActivity( + "error", + `Écoute des événements live indisponible: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + void setup(); + + return () => { + cancelled = true; + for (const unlisten of unlistenFns) { + unlisten(); + } + }; + }, [projectId]); + async function handleDelete() { if (!projectId) return; if (!window.confirm(`Delete project "${project?.name}"?`)) return; @@ -54,6 +243,8 @@ export default function ProjectDashboard() { } const recentTickets = tickets.slice(-10).reverse(); + const activePollList = useMemo(() => Object.entries(activePolls), [activePolls]); + const activeAgentList = useMemo(() => Object.values(activeAgents), [activeAgents]); return (
@@ -101,6 +292,55 @@ export default function ProjectDashboard() {
+
+

Live Activity

+
+
+
+ Polling en cours: {activePollList.length} +
+
+ Agents actifs: {activeAgentList.length} +
+
+ + {(activePollList.length > 0 || activeAgentList.length > 0) && ( +
+ {activePollList.map(([trackerId, label]) => ( +
Polling: {label}
+ ))} + {activeAgentList.map((step, i) => ( +
Agent: {step}
+ ))} +
+ )} + + {activity.length === 0 ? ( +
No live activity yet.
+ ) : ( +
+ {activity.map((item) => ( +
+
{item.message}
+
+ {new Date(item.at).toLocaleTimeString()} +
+
+ ))} +
+ )} +
+
+

Recent Tickets

diff --git a/src/components/trackers/TrackerConfig.tsx b/src/components/trackers/TrackerConfig.tsx index 022b843..e635fa3 100644 --- a/src/components/trackers/TrackerConfig.tsx +++ b/src/components/trackers/TrackerConfig.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { addTracker, getTrackerFields } from "../../lib/api"; +import { useEffect } from "react"; +import { addTracker, getTrackerFields, listTrackers, updateTracker } from "../../lib/api"; import { getErrorMessage } from "../../lib/errors"; import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types"; import FilterBuilder from "./FilterBuilder"; export default function TrackerConfig() { - const { projectId } = useParams<{ projectId: string }>(); + const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>(); const navigate = useNavigate(); + const isEditing = Boolean(trackerConfigId); const [trackerId, setTrackerId] = useState(""); const [trackerLabel, setTrackerLabel] = useState(""); @@ -18,8 +20,56 @@ export default function TrackerConfig() { const [filters, setFilters] = useState([]); const [analystCommand, setAnalystCommand] = useState("claude"); const [developerCommand, setDeveloperCommand] = useState("claude"); + const [enabled, setEnabled] = useState(true); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [initializing, setInitializing] = useState(false); + + function sortTrackerFields(input: TrackerField[]): TrackerField[] { + return input + .map((field) => ({ + ...field, + values: [...field.values].sort((a, b) => + a.label.localeCompare(b.label, "fr", { sensitivity: "base" }) + ), + })) + .sort((a, b) => + a.label.localeCompare(b.label, "fr", { sensitivity: "base" }) + ); + } + + useEffect(() => { + async function loadTrackerForEdit() { + if (!projectId || !trackerConfigId) return; + setInitializing(true); + setError(null); + try { + const trackers = await listTrackers(projectId); + const tracker = trackers.find((t) => t.id === trackerConfigId); + if (!tracker) { + setError("Tracker not found."); + return; + } + setTrackerId(tracker.tracker_id); + setTrackerLabel(tracker.tracker_label); + setPollingInterval(tracker.polling_interval); + setFilters(tracker.filters); + setAnalystCommand(tracker.agent_config.analyst_command); + setDeveloperCommand(tracker.agent_config.developer_command); + setEnabled(tracker.enabled); + + const trackerFields = await getTrackerFields(tracker.tracker_id); + setFields(sortTrackerFields(trackerFields)); + setFieldsLoaded(true); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setInitializing(false); + } + } + + void loadTrackerForEdit(); + }, [projectId, trackerConfigId]); async function handleLoadFields() { if (!trackerId) return; @@ -27,7 +77,7 @@ export default function TrackerConfig() { setError(null); try { const result = await getTrackerFields(Number(trackerId)); - setFields(result); + setFields(sortTrackerFields(result)); setFieldsLoaded(true); } catch (err: unknown) { setError(getErrorMessage(err)); @@ -50,7 +100,26 @@ export default function TrackerConfig() { }; try { - await addTracker(projectId, Number(trackerId), trackerLabel, pollingInterval, agentConfig, filters); + if (isEditing && trackerConfigId) { + await updateTracker( + trackerConfigId, + Number(trackerId), + trackerLabel, + pollingInterval, + agentConfig, + filters, + enabled + ); + } else { + await addTracker( + projectId, + Number(trackerId), + trackerLabel, + pollingInterval, + agentConfig, + filters + ); + } navigate(`/projects/${projectId}`); } catch (err: unknown) { setError(getErrorMessage(err)); @@ -61,9 +130,15 @@ export default function TrackerConfig() { return (
-

Add tracker

+

+ {isEditing ? "Edit tracker" : "Add tracker"} +

+ {initializing && ( +
Loading tracker...
+ )} + {/* Basic fields */}
@@ -122,6 +197,17 @@ export default function TrackerConfig() { className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
+ + {isEditing && ( + + )}
{/* Filter builder */} @@ -172,10 +258,10 @@ export default function TrackerConfig() {
+ + Edit +