feat: improve tracker editing, sorting, and live activity

This commit is contained in:
thibaud-leclere 2026-04-14 11:36:32 +02:00
parent abaf86a6ec
commit a981e189c5
11 changed files with 564 additions and 55 deletions

View file

@ -2,14 +2,15 @@ use crate::error::AppError;
use crate::models::credential::TuleapCredentials; use crate::models::credential::TuleapCredentials;
use crate::models::ticket::ProcessedTicket; use crate::models::ticket::ProcessedTicket;
use crate::models::tracker::WatchedTracker; 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::services::tuleap_client::TuleapClient;
use crate::AppState; use crate::AppState;
use tauri::State; use tauri::{Emitter, State};
#[tauri::command] #[tauri::command]
pub async fn manual_poll( pub async fn manual_poll(
state: State<'_, AppState>, state: State<'_, AppState>,
app_handle: tauri::AppHandle,
tracker_id: String, tracker_id: String,
) -> Result<Vec<ProcessedTicket>, AppError> { ) -> Result<Vec<ProcessedTicket>, AppError> {
let (tracker, client) = { let (tracker, client) = {
@ -36,10 +37,32 @@ pub async fn manual_poll(
(tracker, client) (tracker, client)
}; // lock dropped here }; // lock dropped here
let artifacts = client let _ = app_handle.emit(
.get_artifacts(tracker.tracker_id) "polling-started",
.await serde_json::json!({
.map_err(AppError::from)?; "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); 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)?; 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) Ok(newly_inserted)
} }

View file

@ -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::ticket::ProcessedTicket; 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::crypto;
use crate::services::tuleap_client::TuleapClient; use crate::services::tuleap_client::TuleapClient;
use crate::AppState; use crate::AppState;
@ -73,17 +73,14 @@ pub fn list_trackers(
pub fn update_tracker( pub fn update_tracker(
state: State<'_, AppState>, state: State<'_, AppState>,
id: String, id: String,
polling_interval: i32, update: TrackerUpdate,
agent_config: AgentConfig,
filters: Vec<FilterGroup>,
enabled: bool,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let db = state let db = state
.db .db
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .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(()) Ok(())
} }
@ -104,10 +101,18 @@ pub async fn get_tracker_fields(
tracker_id: i32, tracker_id: i32,
) -> Result<Vec<crate::services::tuleap_client::TrackerField>, AppError> { ) -> Result<Vec<crate::services::tuleap_client::TrackerField>, AppError> {
let client = build_tuleap_client(&state)?; let client = build_tuleap_client(&state)?;
let fields = client let mut fields = client
.get_tracker_fields(tracker_id) .get_tracker_fields(tracker_id)
.await .await
.map_err(AppError::from)?; .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) Ok(fields)
} }

View file

@ -36,6 +36,16 @@ pub struct WatchedTracker {
pub created_at: String, 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<FilterGroup>,
pub enabled: bool,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> { fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
let agent_config_json: String = row.get(5)?; let agent_config_json: String = row.get(5)?;
let filters_json: String = row.get(6)?; let filters_json: String = row.get(6)?;
@ -125,23 +135,24 @@ impl WatchedTracker {
) )
} }
pub fn update( pub fn update(conn: &Connection, id: &str, update: TrackerUpdate) -> Result<()> {
conn: &Connection, let agent_config_json = serde_json::to_string(&update.agent_config)
id: &str,
polling_interval: i32,
agent_config: AgentConfig,
filters: Vec<FilterGroup>,
enabled: bool,
) -> Result<()> {
let agent_config_json = serde_json::to_string(&agent_config)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; .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)))?; .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( let affected = conn.execute(
"UPDATE watched_trackers SET polling_interval = ?1, agent_config_json = ?2, filters_json = ?3, enabled = ?4 WHERE id = ?5", "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![polling_interval, agent_config_json, filters_json, enabled_int, id], params![
update.tracker_id,
update.tracker_label,
update.polling_interval,
agent_config_json,
filters_json,
enabled_int,
id
],
)?; )?;
if affected == 0 { if affected == 0 {
@ -258,10 +269,14 @@ mod tests {
WatchedTracker::update( WatchedTracker::update(
&conn, &conn,
&t2.id, &t2.id,
t2.polling_interval, TrackerUpdate {
sample_agent_config(), tracker_id: t2.tracker_id,
vec![], tracker_label: t2.tracker_label.clone(),
false, polling_interval: t2.polling_interval,
agent_config: sample_agent_config(),
filters: vec![],
enabled: false,
},
) )
.unwrap(); .unwrap();
@ -318,10 +333,23 @@ mod tests {
}], }],
}]; }];
WatchedTracker::update(&conn, &created.id, 60, sample_agent_config(), new_filters, false) WatchedTracker::update(
.expect("update should succeed"); &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(); 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_eq!(updated.polling_interval, 60);
assert!(!updated.enabled); assert!(!updated.enabled);
assert_eq!(updated.filters[0].conditions[0].field, "priority"); assert_eq!(updated.filters[0].conditions[0].field, "priority");

View file

@ -183,7 +183,9 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-started", "ticket-processing-started",
serde_json::json!({ serde_json::json!({
"ticket_id": ticket.id, "project_id": &project.id,
"ticket_id": &ticket.id,
"artifact_id": ticket.artifact_id,
"step": "analyst", "step": "analyst",
}), }),
); );
@ -216,7 +218,12 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
); );
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-error", "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); return Ok(true);
} }
@ -235,7 +242,11 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
.map_err(|e| format!("update_status: {}", e))?; .map_err(|e| format!("update_status: {}", e))?;
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-done", "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( notifier::notify_analysis_done(
db, db,
@ -272,7 +283,12 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
); );
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-error", "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<Mutex<Connection>>, app_handle: &AppHandle) ->
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-started", "ticket-processing-started",
serde_json::json!({ serde_json::json!({
"ticket_id": ticket.id, "project_id": &project.id,
"ticket_id": &ticket.id,
"artifact_id": ticket.artifact_id,
"step": "developer", "step": "developer",
}), }),
); );
@ -324,7 +342,12 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
); );
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-error", "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); return Ok(true);
} }
@ -340,7 +363,11 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
let _ = app_handle.emit( let _ = app_handle.emit(
"ticket-processing-done", "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( notifier::notify_fix_ready(
db, db,

View file

@ -102,11 +102,31 @@ async fn poll_single_tracker(
tracker: &WatchedTracker, tracker: &WatchedTracker,
app_handle: &AppHandle, 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 // 1. Fetch artifacts
let artifacts = match client.get_artifacts(tracker.tracker_id).await { let artifacts = match client.get_artifacts(tracker.tracker_id).await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
eprintln!("poller: failed to fetch artifacts for tracker {}: {}", tracker.id, 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; return;
} }
}; };
@ -169,8 +189,9 @@ async fn poll_single_tracker(
if let Err(e) = app_handle.emit( if let Err(e) = app_handle.emit(
"new-tickets-detected", "new-tickets-detected",
serde_json::json!({ serde_json::json!({
"tracker_id": tracker.id, "project_id": &tracker.project_id,
"tracker_label": tracker.tracker_label, "tracker_id": &tracker.id,
"tracker_label": &tracker.tracker_label,
"count": new_tickets.len(), "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(),
}),
);
} }

View file

@ -26,6 +26,7 @@ function App() {
<Route path="/projects/:projectId/tickets" element={<TicketList />} /> <Route path="/projects/:projectId/tickets" element={<TicketList />} />
<Route path="/projects/:projectId/edit" element={<ProjectForm />} /> <Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} /> <Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
<Route path="/projects/:projectId/trackers/:trackerConfigId/edit" element={<TrackerConfig />} />
<Route path="/tickets/:ticketId" element={<TicketDetail />} /> <Route path="/tickets/:ticketId" element={<TicketDetail />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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 { useParams, Link, useNavigate } from "react-router-dom";
import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api"; import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api";
import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types"; import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types";
import TrackerList from "../trackers/TrackerList"; 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() { export default function ProjectDashboard() {
const { projectId } = useParams(); const { projectId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
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 [activity, setActivity] = useState<ActivityItem[]>([]);
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
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() { async function loadData() {
if (!projectId) return; if (!projectId) return;
@ -27,6 +69,153 @@ export default function ProjectDashboard() {
loadData(); loadData();
}, [projectId]); }, [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<PollingPayload>("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<PollingPayload>("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<PollingPayload>("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<PollingPayload>("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<TicketProcessingPayload>("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<TicketProcessingPayload>("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<TicketProcessingPayload>("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() { async function handleDelete() {
if (!projectId) return; if (!projectId) return;
if (!window.confirm(`Delete project "${project?.name}"?`)) return; if (!window.confirm(`Delete project "${project?.name}"?`)) return;
@ -54,6 +243,8 @@ export default function ProjectDashboard() {
} }
const recentTickets = tickets.slice(-10).reverse(); const recentTickets = tickets.slice(-10).reverse();
const activePollList = useMemo(() => Object.entries(activePolls), [activePolls]);
const activeAgentList = useMemo(() => Object.values(activeAgents), [activeAgents]);
return ( return (
<div className="p-8"> <div className="p-8">
@ -101,6 +292,55 @@ export default function ProjectDashboard() {
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} /> <TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
</div> </div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Live Activity</h3>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className="flex flex-wrap gap-3">
<div className="text-sm rounded-full bg-blue-50 text-blue-700 px-3 py-1">
Polling en cours: {activePollList.length}
</div>
<div className="text-sm rounded-full bg-purple-50 text-purple-700 px-3 py-1">
Agents actifs: {activeAgentList.length}
</div>
</div>
{(activePollList.length > 0 || activeAgentList.length > 0) && (
<div className="text-xs text-gray-500 space-y-1">
{activePollList.map(([trackerId, label]) => (
<div key={`poll-${trackerId}`}>Polling: {label}</div>
))}
{activeAgentList.map((step, i) => (
<div key={`agent-${i}`}>Agent: {step}</div>
))}
</div>
)}
{activity.length === 0 ? (
<div className="text-sm text-gray-400">No live activity yet.</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{activity.map((item) => (
<div
key={item.id}
className={`rounded border px-3 py-2 text-sm ${
item.level === "error"
? "border-red-200 bg-red-50 text-red-700"
: item.level === "success"
? "border-green-200 bg-green-50 text-green-700"
: "border-blue-200 bg-blue-50 text-blue-700"
}`}
>
<div>{item.message}</div>
<div className="text-[11px] opacity-70 mt-0.5">
{new Date(item.at).toLocaleTimeString()}
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-8"> <div className="mt-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Recent Tickets</h3> <h3 className="text-lg font-semibold">Recent Tickets</h3>

View file

@ -1,13 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; 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 { getErrorMessage } from "../../lib/errors";
import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types"; import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types";
import FilterBuilder from "./FilterBuilder"; import FilterBuilder from "./FilterBuilder";
export default function TrackerConfig() { export default function TrackerConfig() {
const { projectId } = useParams<{ projectId: string }>(); const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isEditing = Boolean(trackerConfigId);
const [trackerId, setTrackerId] = useState<number | "">(""); const [trackerId, setTrackerId] = useState<number | "">("");
const [trackerLabel, setTrackerLabel] = useState(""); const [trackerLabel, setTrackerLabel] = useState("");
@ -18,8 +20,56 @@ export default function TrackerConfig() {
const [filters, setFilters] = useState<FilterGroup[]>([]); const [filters, setFilters] = useState<FilterGroup[]>([]);
const [analystCommand, setAnalystCommand] = useState("claude"); const [analystCommand, setAnalystCommand] = useState("claude");
const [developerCommand, setDeveloperCommand] = useState("claude"); const [developerCommand, setDeveloperCommand] = useState("claude");
const [enabled, setEnabled] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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() { async function handleLoadFields() {
if (!trackerId) return; if (!trackerId) return;
@ -27,7 +77,7 @@ export default function TrackerConfig() {
setError(null); setError(null);
try { try {
const result = await getTrackerFields(Number(trackerId)); const result = await getTrackerFields(Number(trackerId));
setFields(result); setFields(sortTrackerFields(result));
setFieldsLoaded(true); setFieldsLoaded(true);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
@ -50,7 +100,26 @@ export default function TrackerConfig() {
}; };
try { 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}`); navigate(`/projects/${projectId}`);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
@ -61,9 +130,15 @@ export default function TrackerConfig() {
return ( return (
<div className="max-w-2xl mx-auto p-8"> <div className="max-w-2xl mx-auto p-8">
<h2 className="text-xl font-bold mb-6">Add tracker</h2> <h2 className="text-xl font-bold mb-6">
{isEditing ? "Edit tracker" : "Add tracker"}
</h2>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{initializing && (
<div className="text-sm text-gray-500">Loading tracker...</div>
)}
{/* Basic fields */} {/* Basic fields */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4"> <div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div> <div>
@ -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" className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
{isEditing && (
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Tracker enabled
</label>
)}
</div> </div>
{/* Filter builder */} {/* Filter builder */}
@ -172,10 +258,10 @@ export default function TrackerConfig() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || initializing}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
> >
{loading ? "Adding..." : "Add tracker"} {loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"}
</button> </button>
<button <button
type="button" type="button"

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { manualPoll, updateTracker, removeTracker } from "../../lib/api"; import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
import type { WatchedTracker } from "../../lib/types"; import type { WatchedTracker } from "../../lib/types";
@ -9,12 +10,17 @@ interface Props {
} }
export default function TrackerList({ trackers, projectId, onRefresh }: Props) { export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
const [pollingIds, setPollingIds] = useState<string[]>([]);
async function handlePollNow(tracker: WatchedTracker) { async function handlePollNow(tracker: WatchedTracker) {
try { try {
setPollingIds((prev) => [...prev, tracker.id]);
await manualPoll(tracker.id); await manualPoll(tracker.id);
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
console.error("Poll failed:", err); console.error("Poll failed:", err);
} finally {
setPollingIds((prev) => prev.filter((id) => id !== tracker.id));
} }
} }
@ -22,6 +28,8 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
try { try {
await updateTracker( await updateTracker(
tracker.id, tracker.id,
tracker.tracker_id,
tracker.tracker_label,
tracker.polling_interval, tracker.polling_interval,
tracker.agent_config, tracker.agent_config,
tracker.filters, tracker.filters,
@ -79,10 +87,17 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<button <button
type="button" type="button"
onClick={() => handlePollNow(tracker)} onClick={() => handlePollNow(tracker)}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700" disabled={pollingIds.includes(tracker.id)}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:opacity-50"
> >
Poll now {pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
</button> </button>
<Link
to={`/projects/${projectId}/trackers/${tracker.id}/edit`}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
>
Edit
</Link>
<button <button
type="button" type="button"
onClick={() => handleToggleEnabled(tracker)} onClick={() => handleToggleEnabled(tracker)}

View file

@ -65,8 +65,26 @@ export async function addTracker(projectId: string, trackerId: number, trackerLa
export async function listTrackers(projectId: string): Promise<WatchedTracker[]> { export async function listTrackers(projectId: string): Promise<WatchedTracker[]> {
return invoke("list_trackers", { projectId }); return invoke("list_trackers", { projectId });
} }
export async function updateTracker(id: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[], enabled: boolean): Promise<void> { export async function updateTracker(
return invoke("update_tracker", { id, pollingInterval, agentConfig, filters, enabled }); id: string,
trackerId: number,
trackerLabel: string,
pollingInterval: number,
agentConfig: AgentConfig,
filters: FilterGroup[],
enabled: boolean
): Promise<void> {
return invoke("update_tracker", {
id,
update: {
tracker_id: trackerId,
tracker_label: trackerLabel,
polling_interval: pollingInterval,
agent_config: agentConfig,
filters,
enabled,
},
});
} }
export async function removeTracker(id: string): Promise<void> { export async function removeTracker(id: string): Promise<void> {
return invoke("remove_tracker", { id }); return invoke("remove_tracker", { id });

View file

@ -32,9 +32,9 @@ export function getErrorMessage(err: unknown): string {
try { try {
return JSON.stringify(err); return JSON.stringify(err);
} catch { } catch {
// fallthrough to String below return "Erreur inattendue";
} }
} }
return String(err); return String(err ?? "Erreur inattendue");
} }