489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
import { listen } from "@tauri-apps/api/event";
|
||
import { useEffect, useState } from "react";
|
||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||
import {
|
||
getProject,
|
||
deleteProject,
|
||
listTrackers,
|
||
listProcessedTickets,
|
||
getProjectThroughput,
|
||
} from "../../lib/api";
|
||
import type {
|
||
Project,
|
||
WatchedTracker,
|
||
ProcessedTicket,
|
||
ProjectThroughputStats,
|
||
} from "../../lib/types";
|
||
import TrackerList from "../trackers/TrackerList";
|
||
import ConfirmModal from "../ui/ConfirmModal";
|
||
|
||
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 [throughput, setThroughput] = useState<ProjectThroughputStats | null>(null);
|
||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||
const [activePolls, setActivePolls] = useState<Record<string, string>>({});
|
||
const [activeAgents, setActiveAgents] = useState<Record<string, string>>({});
|
||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||
|
||
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;
|
||
const [proj, trks, tkts, stats] = await Promise.all([
|
||
getProject(projectId),
|
||
listTrackers(projectId),
|
||
listProcessedTickets(projectId),
|
||
getProjectThroughput(projectId),
|
||
]);
|
||
setProject(proj);
|
||
setTrackers(trks);
|
||
setTickets(tkts);
|
||
setThroughput(stats);
|
||
}
|
||
|
||
useEffect(() => {
|
||
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;
|
||
setIsDeleteModalOpen(false);
|
||
await deleteProject(projectId);
|
||
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
||
navigate("/");
|
||
}
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
function formatLeadTime(seconds: number | null): string {
|
||
if (seconds === null || Number.isNaN(seconds)) {
|
||
return "—";
|
||
}
|
||
|
||
if (seconds < 60) {
|
||
return `${Math.round(seconds)} s`;
|
||
}
|
||
if (seconds < 3600) {
|
||
return `${Math.round(seconds / 60)} min`;
|
||
}
|
||
if (seconds < 86400) {
|
||
return `${(seconds / 3600).toFixed(1)} h`;
|
||
}
|
||
return `${(seconds / 86400).toFixed(1)} j`;
|
||
}
|
||
|
||
if (!project) {
|
||
return <div className="p-8 text-gray-400">Loading...</div>;
|
||
}
|
||
|
||
const recentTickets = tickets.slice(-10).reverse();
|
||
const activePollList = Object.entries(activePolls);
|
||
const activeAgentList = Object.values(activeAgents);
|
||
const done24h = throughput?.done_last_24h ?? 0;
|
||
const error24h = throughput?.error_last_24h ?? 0;
|
||
const resolved24h = done24h + error24h;
|
||
const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—";
|
||
|
||
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
|
||
onClick={() => setIsDeleteModalOpen(true)}
|
||
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>
|
||
|
||
<div className="mt-8">
|
||
<h3 className="text-lg font-semibold mb-4">Throughput & santé (24h)</h3>
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||
<div className="text-xs uppercase tracking-wide text-gray-500">Backlog</div>
|
||
<div className="mt-2 text-2xl font-semibold text-gray-900">
|
||
{throughput?.backlog_count ?? 0}
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-500">Tickets encore en cours.</div>
|
||
</div>
|
||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||
<div className="text-xs uppercase tracking-wide text-green-700">Done</div>
|
||
<div className="mt-2 text-2xl font-semibold text-green-900">{done24h}</div>
|
||
<div className="mt-1 text-xs text-green-700">Tickets finalisés en 24h.</div>
|
||
</div>
|
||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||
<div className="text-xs uppercase tracking-wide text-red-700">Error</div>
|
||
<div className="mt-2 text-2xl font-semibold text-red-900">{error24h}</div>
|
||
<div className="mt-1 text-xs text-red-700">Taux d’erreur 24h: {errorRate24h}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||
<div className="text-xs uppercase tracking-wide text-blue-700">Lead time moyen</div>
|
||
<div className="mt-2 text-2xl font-semibold text-blue-900">
|
||
{formatLeadTime(throughput?.avg_lead_time_seconds ?? null)}
|
||
</div>
|
||
<div className="mt-1 text-xs text-blue-700">Sur les tickets clos en 24h.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<div className="mt-8">
|
||
<h3 className="text-lg font-semibold mb-4">Watched Trackers</h3>
|
||
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
|
||
</div>
|
||
|
||
<ConfirmModal
|
||
isOpen={isDeleteModalOpen}
|
||
onCancel={() => setIsDeleteModalOpen(false)}
|
||
onConfirm={() => void handleDelete()}
|
||
/>
|
||
|
||
<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>
|
||
{tickets.length > 0 && (
|
||
<Link
|
||
to={`/projects/${project.id}/tickets`}
|
||
className="text-sm text-blue-600 hover:underline"
|
||
>
|
||
View all ({tickets.length})
|
||
</Link>
|
||
)}
|
||
</div>
|
||
{recentTickets.length === 0 ? (
|
||
<div className="text-sm text-gray-400">No tickets processed yet.</div>
|
||
) : (
|
||
<div className="mt-4 space-y-3">
|
||
{recentTickets.map((ticket) => (
|
||
<Link
|
||
key={ticket.id}
|
||
to={`/tickets/${ticket.id}`}
|
||
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>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|