feat: improve tracker editing, sorting, and live activity
This commit is contained in:
parent
abaf86a6ec
commit
a981e189c5
11 changed files with 564 additions and 55 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue