orchai/src/components/projects/ProjectDashboard.tsx

489 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 derreur 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>
);
}