orchai/src/components/projects/ProjectDashboard.tsx

392 lines
13 KiB
TypeScript
Raw Normal View History

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 } from "../../lib/api";
import type { Project, WatchedTracker, ProcessedTicket } 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 [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] = await Promise.all([
getProject(projectId),
listTrackers(projectId),
listProcessedTickets(projectId),
]);
setProject(proj);
setTrackers(trks);
setTickets(tkts);
}
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";
}
}
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);
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">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>
);
}