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::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<Vec<ProcessedTicket>, 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)
}

View file

@ -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<FilterGroup>,
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<Vec<crate::services::tuleap_client::TrackerField>, 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)
}

View file

@ -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<FilterGroup>,
pub enabled: bool,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
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<FilterGroup>,
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");

View file

@ -183,7 +183,9 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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,

View file

@ -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(),
}),
);
}

View file

@ -26,6 +26,7 @@ function App() {
<Route path="/projects/:projectId/tickets" element={<TicketList />} />
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<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="/settings" element={<SettingsPage />} />
<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 { 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<Project | null>(null);
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
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() {
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<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() {
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 (
<div className="p-8">
@ -101,6 +292,55 @@ export default function ProjectDashboard() {
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
</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="flex items-center justify-between">
<h3 className="text-lg font-semibold">Recent Tickets</h3>

View file

@ -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<number | "">("");
const [trackerLabel, setTrackerLabel] = useState("");
@ -18,8 +20,56 @@ export default function TrackerConfig() {
const [filters, setFilters] = useState<FilterGroup[]>([]);
const [analystCommand, setAnalystCommand] = useState("claude");
const [developerCommand, setDeveloperCommand] = useState("claude");
const [enabled, setEnabled] = useState(true);
const [error, setError] = useState<string | null>(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 (
<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">
{initializing && (
<div className="text-sm text-gray-500">Loading tracker...</div>
)}
{/* Basic fields */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<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"
/>
</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>
{/* Filter builder */}
@ -172,10 +258,10 @@ export default function TrackerConfig() {
<div className="flex gap-2">
<button
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"
>
{loading ? "Adding..." : "Add tracker"}
{loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"}
</button>
<button
type="button"

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
import type { WatchedTracker } from "../../lib/types";
@ -9,12 +10,17 @@ interface Props {
}
export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
const [pollingIds, setPollingIds] = useState<string[]>([]);
async function handlePollNow(tracker: WatchedTracker) {
try {
setPollingIds((prev) => [...prev, tracker.id]);
await manualPoll(tracker.id);
onRefresh();
} catch (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 {
await updateTracker(
tracker.id,
tracker.tracker_id,
tracker.tracker_label,
tracker.polling_interval,
tracker.agent_config,
tracker.filters,
@ -79,10 +87,17 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<button
type="button"
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>
<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
type="button"
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[]> {
return invoke("list_trackers", { projectId });
}
export async function updateTracker(id: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[], enabled: boolean): Promise<void> {
return invoke("update_tracker", { id, pollingInterval, agentConfig, filters, enabled });
export async function updateTracker(
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> {
return invoke("remove_tracker", { id });

View file

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