2026-04-14 09:36:32 +00:00
|
|
|
import { listen } from "@tauri-apps/api/event";
|
2026-04-14 09:43:42 +00:00
|
|
|
import { useEffect, useState } from "react";
|
2026-04-13 08:02:48 +00:00
|
|
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
2026-04-13 12:50:13 +00:00
|
|
|
import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api";
|
|
|
|
|
import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types";
|
|
|
|
|
import TrackerList from "../trackers/TrackerList";
|
2026-04-14 13:27:29 +00:00
|
|
|
import ConfirmModal from "../ui/ConfirmModal";
|
2026-04-13 08:02:48 +00:00
|
|
|
|
2026-04-14 09:36:32 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 08:02:48 +00:00
|
|
|
export default function ProjectDashboard() {
|
|
|
|
|
const { projectId } = useParams();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
2026-04-13 12:50:13 +00:00
|
|
|
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
|
|
|
|
|
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
2026-04-14 09:36:32 +00:00
|
|
|
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
|
|
|
|
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
|
|
|
|
|
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
|
2026-04-14 13:27:29 +00:00
|
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
2026-04-14 09:36:32 +00:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
2026-04-13 12:50:13 +00:00
|
|
|
|
|
|
|
|
async function loadData() {
|
|
|
|
|
if (!projectId) return;
|
|
|
|
|
const [proj, trks, tkts] = await Promise.all([
|
|
|
|
|
getProject(projectId),
|
|
|
|
|
listTrackers(projectId),
|
|
|
|
|
listProcessedTickets(projectId),
|
|
|
|
|
]);
|
|
|
|
|
setProject(proj);
|
|
|
|
|
setTrackers(trks);
|
|
|
|
|
setTickets(tkts);
|
|
|
|
|
}
|
2026-04-13 08:02:48 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-13 12:50:13 +00:00
|
|
|
loadData();
|
2026-04-13 08:02:48 +00:00
|
|
|
}, [projectId]);
|
|
|
|
|
|
2026-04-14 09:36:32 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-13 08:02:48 +00:00
|
|
|
async function handleDelete() {
|
|
|
|
|
if (!projectId) return;
|
2026-04-14 13:27:29 +00:00
|
|
|
setIsDeleteModalOpen(false);
|
2026-04-13 08:02:48 +00:00
|
|
|
await deleteProject(projectId);
|
|
|
|
|
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
|
|
|
|
navigate("/");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:50:13 +00:00
|
|
|
function statusBadgeClass(status: string): string {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case "Pending":
|
|
|
|
|
return "bg-yellow-100 text-yellow-700";
|
|
|
|
|
case "Done":
|
|
|
|
|
return "bg-green-100 text-green-700";
|
|
|
|
|
case "Error":
|
|
|
|
|
return "bg-red-100 text-red-700";
|
|
|
|
|
default:
|
|
|
|
|
return "bg-blue-100 text-blue-700";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 08:02:48 +00:00
|
|
|
if (!project) {
|
|
|
|
|
return <div className="p-8 text-gray-400">Loading...</div>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:50:13 +00:00
|
|
|
const recentTickets = tickets.slice(-10).reverse();
|
2026-04-14 09:43:42 +00:00
|
|
|
const activePollList = Object.entries(activePolls);
|
|
|
|
|
const activeAgentList = Object.values(activeAgents);
|
2026-04-13 12:50:13 +00:00
|
|
|
|
2026-04-13 08:02:48 +00:00
|
|
|
return (
|
|
|
|
|
<div className="p-8">
|
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
|
|
|
<h2 className="text-xl font-bold">{project.name}</h2>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/projects/${project.id}/edit`}
|
|
|
|
|
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
|
|
|
|
>
|
|
|
|
|
Edit
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
2026-04-14 13:27:29 +00:00
|
|
|
onClick={() => setIsDeleteModalOpen(true)}
|
2026-04-13 08:02:48 +00:00
|
|
|
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-sm text-gray-500">Path:</span>
|
|
|
|
|
<span className="ml-2 text-sm font-mono">{project.path}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{project.cloned_from && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-sm text-gray-500">Cloned from:</span>
|
|
|
|
|
<span className="ml-2 text-sm font-mono">{project.cloned_from}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-sm text-gray-500">Base branch:</span>
|
|
|
|
|
<span className="ml-2 text-sm font-mono">{project.base_branch}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-sm text-gray-500">Created:</span>
|
|
|
|
|
<span className="ml-2 text-sm">{new Date(project.created_at).toLocaleDateString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-15 15:17:23 +00:00
|
|
|
<div className="mt-8">
|
|
|
|
|
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/projects/${project.id}/modules`}
|
|
|
|
|
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-sm font-semibold text-gray-900">Modules</div>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
|
|
|
Active ou désactive les modules IA du projet.
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/projects/${project.id}/live-agent`}
|
|
|
|
|
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-sm font-semibold text-gray-900">Live agent</div>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
|
|
|
Discussion live avec un agent dans le contexte du repo.
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/projects/${project.id}/tasks`}
|
|
|
|
|
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-sm font-semibold text-gray-900">Tâches</div>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
|
|
|
Crée une file de tâches traitées par des agents pré-définis.
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-13 12:50:13 +00:00
|
|
|
<div className="mt-8">
|
|
|
|
|
<h3 className="text-lg font-semibold mb-4">Watched Trackers</h3>
|
|
|
|
|
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-14 13:27:29 +00:00
|
|
|
<ConfirmModal
|
|
|
|
|
isOpen={isDeleteModalOpen}
|
|
|
|
|
onCancel={() => setIsDeleteModalOpen(false)}
|
|
|
|
|
onConfirm={() => void handleDelete()}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-04-14 09:36:32 +00:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-13 12:50:13 +00:00
|
|
|
<div className="mt-8">
|
2026-04-14 07:18:11 +00:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="text-lg font-semibold">Recent Tickets</h3>
|
|
|
|
|
{tickets.length > 0 && (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/projects/${project.id}/tickets`}
|
|
|
|
|
className="text-sm text-blue-600 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
View all ({tickets.length})
|
|
|
|
|
</Link>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-13 12:50:13 +00:00
|
|
|
{recentTickets.length === 0 ? (
|
|
|
|
|
<div className="text-sm text-gray-400">No tickets processed yet.</div>
|
|
|
|
|
) : (
|
2026-04-14 07:18:11 +00:00
|
|
|
<div className="mt-4 space-y-3">
|
2026-04-13 12:50:13 +00:00
|
|
|
{recentTickets.map((ticket) => (
|
2026-04-14 07:18:11 +00:00
|
|
|
<Link
|
2026-04-13 12:50:13 +00:00
|
|
|
key={ticket.id}
|
2026-04-14 07:18:11 +00:00
|
|
|
to={`/tickets/${ticket.id}`}
|
2026-04-13 12:50:13 +00:00
|
|
|
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-gray-400 font-mono">#{ticket.artifact_id}</span>
|
|
|
|
|
<span className="text-sm font-medium truncate">{ticket.artifact_title}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span
|
|
|
|
|
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${statusBadgeClass(ticket.status)}`}
|
|
|
|
|
>
|
|
|
|
|
{ticket.status}
|
|
|
|
|
</span>
|
2026-04-14 07:18:11 +00:00
|
|
|
</Link>
|
2026-04-13 12:50:13 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-13 08:02:48 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|