Add Run managemement options and pagination to simulations

This commit is contained in:
akhisud3195 2025-02-18 10:50:46 +05:30
parent 893f215f4c
commit 4261c70a2b
4 changed files with 331 additions and 127 deletions

View file

@ -247,3 +247,24 @@ export async function getAggregateResult(
return run.aggregateResults; return run.aggregateResults;
} }
export async function deleteRun(projectId: string, runId: string) {
try {
// Delete the run using the collection directly
await simulationRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId: projectId
});
// Delete associated results using the collection directly
await simulationResultsCollection.deleteMany({
runId: runId,
projectId: projectId
});
return { success: true };
} catch (error) {
console.error('Error deleting run:', error);
throw new Error('Failed to delete run');
}
}

View file

@ -47,12 +47,16 @@ export const SimulationAggregateResult = z.object({
export const SimulationRun = z.object({ export const SimulationRun = z.object({
projectId: z.string(), projectId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed']),
scenarioIds: z.array(z.string()), scenarioIds: z.array(z.string()),
workflowId: z.string(), workflowId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed', 'error']),
startedAt: z.string(), startedAt: z.string(),
completedAt: z.string().optional(), completedAt: z.string().optional(),
aggregateResults: SimulationAggregateResult.optional(), aggregateResults: z.object({
total: z.number(),
pass: z.number(),
fail: z.number(),
}).optional(),
}); });
export const SimulationResult = z.object({ export const SimulationResult = z.object({

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { import {
getScenarios, getScenarios,
@ -15,6 +15,7 @@ import {
createRunResult, createRunResult,
updateRunStatus, updateRunStatus,
createAggregateResult, createAggregateResult,
deleteRun,
} from '../../../actions/simulation_actions'; } from '../../../actions/simulation_actions';
import { type WithStringId } from '../../../lib/types/types'; import { type WithStringId } from '../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types"; import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
@ -77,6 +78,13 @@ export default function SimulationApp() {
const [isLoadingRuns, setIsLoadingRuns] = useState(true); const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({}); const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({}); const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [menuOpenId, setMenuOpenIdState] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const runsPerPage = 10;
const setMenuOpenId = useCallback((id: string | null) => {
setMenuOpenIdState(id);
}, []);
// Load scenarios on mount // Load scenarios on mount
useEffect(() => { useEffect(() => {
@ -216,8 +224,9 @@ export default function SimulationApp() {
} }
// First verify the workflow exists before creating the run // First verify the workflow exists before creating the run
let workflow;
try { try {
await fetchWorkflow(projectId as string, workflowId); workflow = await fetchWorkflow(projectId as string, workflowId);
} catch (error) { } catch (error) {
// If workflow doesn't exist, clear localStorage and throw error // If workflow doesn't exist, clear localStorage and throw error
localStorage.removeItem(`lastWorkflowId_${projectId}`); localStorage.removeItem(`lastWorkflowId_${projectId}`);
@ -231,8 +240,7 @@ export default function SimulationApp() {
); );
setActiveRun(newRun); setActiveRun(newRun);
// Fetch and store workflow version // Store workflow version
const workflow = await fetchWorkflow(projectId as string, workflowId);
setWorkflowVersions(prev => ({ setWorkflowVersions(prev => ({
...prev, ...prev,
[workflowId]: workflow [workflowId]: workflow
@ -282,7 +290,7 @@ export default function SimulationApp() {
await fetchRuns(); await fetchRuns();
} catch (error) { } catch (error) {
console.error('Error starting scenarios:', error); console.error('Error starting scenarios:', error);
// Maybe show an error toast here alert(error instanceof Error ? error.message : 'An error occurred while starting scenarios');
} finally { } finally {
setIsRunning(false); setIsRunning(false);
} }
@ -296,7 +304,7 @@ export default function SimulationApp() {
setMenuOpenScenarioId(null); setMenuOpenScenarioId(null);
}; };
// Add useEffect to fetch workflow versions for existing runs // Update the workflow versions fetching effect
useEffect(() => { useEffect(() => {
if (!projectId || !runs.length) return; if (!projectId || !runs.length) return;
@ -310,17 +318,17 @@ export default function SimulationApp() {
versions[workflowId] = workflow; versions[workflowId] = workflow;
} catch (error) { } catch (error) {
console.error(`Error fetching workflow ${workflowId}:`, error); console.error(`Error fetching workflow ${workflowId}:`, error);
// Add a placeholder for deleted workflows // Add a placeholder for deleted/invalid workflows
versions[workflowId] = { versions[workflowId] = {
_id: workflowId, _id: workflowId,
name: "Deleted Workflow", name: "Deleted/Invalid Workflow",
projectId: projectId as string, projectId: projectId as string,
agents: [], agents: [],
prompts: [], prompts: [],
tools: [], tools: [],
startAgent: "", startAgent: "",
createdAt: "", createdAt: new Date().toISOString(),
lastUpdatedAt: "", lastUpdatedAt: new Date().toISOString(),
}; };
} }
} }
@ -331,6 +339,31 @@ export default function SimulationApp() {
fetchWorkflowVersions(); fetchWorkflowVersions();
}, [projectId, runs]); }, [projectId, runs]);
const handleCancelRun = async (runId: string) => {
if (!projectId) return;
try {
await updateRunStatus(projectId as string, runId, 'cancelled');
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error cancelling run:', error);
}
};
const handleDeleteRun = async (runId: string) => {
if (!projectId) return;
try {
await deleteRun(projectId as string, runId);
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error deleting run:', error);
}
};
const indexOfLastRun = currentPage * runsPerPage;
const indexOfFirstRun = indexOfLastRun - runsPerPage;
const currentRuns = runs.slice(indexOfFirstRun, indexOfLastRun);
const totalPages = Math.ceil(runs.length / runsPerPage);
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
{/* Left sidebar */} {/* Left sidebar */}
@ -436,17 +469,56 @@ export default function SimulationApp() {
) : runs.length === 0 ? ( ) : runs.length === 0 ? (
<div className="text-center py-4 text-gray-500">No simulation runs yet</div> <div className="text-center py-4 text-gray-500">No simulation runs yet</div>
) : ( ) : (
<>
<div className="space-y-4"> <div className="space-y-4">
{runs.map((run) => ( {currentRuns.map((run) => (
<SimulationResultCard <SimulationResultCard
key={run._id} key={run._id}
run={run} run={run}
results={allRunResults[run._id] || []} results={allRunResults[run._id] || []}
scenarios={scenarios} scenarios={scenarios}
workflow={workflowVersions[run.workflowId]} workflow={workflowVersions[run.workflowId]}
onCancelRun={handleCancelRun}
onDeleteRun={handleDeleteRun}
menuOpenId={menuOpenId}
setMenuOpenId={setMenuOpenId}
/> />
))} ))}
</div> </div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center space-x-4 mt-6">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`p-2 rounded-full ${
currentPage === 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className={`p-2 rounded-full ${
currentPage === totalPages
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
)}
</>
)} )}
</div> </div>
)} )}

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon, ChevronRightIcon, NoSymbolIcon, EllipsisVerticalIcon, ArrowDownTrayIcon, TrashIcon } from '@heroicons/react/24/outline';
import { WithStringId } from '../../../../lib/types/types'; import { WithStringId } from '../../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types"; import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types";
import { z } from 'zod'; import { z } from 'zod';
@ -16,29 +16,38 @@ interface SimulationResultCardProps {
results: SimulationResultType[]; results: SimulationResultType[];
scenarios: ScenarioType[]; scenarios: ScenarioType[];
workflow?: WithStringId<z.infer<typeof Workflow>>; workflow?: WithStringId<z.infer<typeof Workflow>>;
onCancelRun?: (runId: string) => void;
onDeleteRun?: (runId: string) => Promise<void>;
menuOpenId: string | null;
setMenuOpenId: (id: string | null) => void;
} }
export const SimulationResultCard = ({ run, results, scenarios, workflow }: SimulationResultCardProps) => { export const SimulationResultCard = ({ run, results, scenarios, workflow, onCancelRun, onDeleteRun, menuOpenId, setMenuOpenId }: SimulationResultCardProps) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [expandedScenarios, setExpandedScenarios] = useState<Set<string>>(new Set()); const [expandedScenarios, setExpandedScenarios] = useState<Set<string>>(new Set());
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const totalScenarios = run.aggregateResults?.total ?? run.scenarioIds.length; const totalScenarios = run.aggregateResults?.total ?? run.scenarioIds.length;
const passedScenarios = run.aggregateResults?.pass ?? 0; const passedScenarios = run.aggregateResults?.pass ?? 0;
const failedScenarios = run.aggregateResults?.fail ?? 0; const failedScenarios = run.aggregateResults?.fail ?? 0;
const statusLabelClass = "px-3 py-1 rounded text-xs min-w-[60px] text-center uppercase font-semibold"; const statusLabelClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
const getStatusClass = (status: string) => { const getStatusClass = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
case 'pass': case 'pass':
return `${statusLabelClass} bg-green-100 text-green-800`; return `${statusLabelClass} bg-green-50 text-green-800`;
case 'failed': case 'failed':
case 'fail': case 'fail':
return `${statusLabelClass} bg-red-100 text-red-800`; return `${statusLabelClass} bg-red-50 text-red-800`;
case 'error':
return `${statusLabelClass} bg-orange-50 text-orange-800`;
case 'cancelled':
return `${statusLabelClass} bg-gray-50 text-gray-800`;
case 'running': case 'running':
case 'pending': case 'pending':
default: default:
return `${statusLabelClass} bg-yellow-100 text-yellow-800`; return `${statusLabelClass} bg-yellow-50 text-yellow-800`;
} }
}; };
@ -87,6 +96,14 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow }: Simu
}); });
}; };
useEffect(() => {
if (menuOpenId) {
const closeMenu = () => setMenuOpenId(null);
window.addEventListener('click', closeMenu);
return () => window.removeEventListener('click', closeMenu);
}
}, [menuOpenId, setMenuOpenId]);
return ( return (
<div className="border rounded-lg mb-4 shadow-sm"> <div className="border rounded-lg mb-4 shadow-sm">
<div <div
@ -103,13 +120,70 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow }: Simu
{formatMainTitle(run.startedAt)} {formatMainTitle(run.startedAt)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<span className={getStatusClass(run.status)}> <span className={getStatusClass(run.status)}>
{run.status} {run.status}
</span> </span>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpenId(menuOpenId === run._id ? null : run._id);
}}
className="p-1 rounded-full hover:bg-gray-100"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
</button>
{menuOpenId === run._id && (
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div className="py-1">
{(run.status === 'running' || run.status === 'pending') && onCancelRun && (
<button
onClick={(e) => {
e.stopPropagation();
onCancelRun(run._id);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
>
<NoSymbolIcon className="h-4 w-4 mr-2" />
Cancel Run
</button>
)}
<button
disabled
className="flex items-center px-4 py-2 text-sm text-gray-400 w-full cursor-not-allowed whitespace-nowrap"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
Download transcripts
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete run
</button>
</div>
</div>
)}
</div>
</div>
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="p-4 border-t"> <div className="p-4 border-t">
{run.status === 'error' ? (
<div className="text-orange-800 bg-orange-50 p-4 rounded-lg">
Your simulation could not be completed. Please run a new simulation again.
</div>
) : (
<>
{/* Workflow and timing information in a grid */} {/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4 mb-6"> <div className="grid grid-cols-3 gap-4 mb-6">
{workflow && ( {workflow && (
@ -214,6 +288,39 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow }: Simu
); );
})} })}
</div> </div>
</>
)}
</div>
)}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<h3 className="text-lg leading-6 font-medium text-gray-900 whitespace-nowrap">
Are you sure you want to delete this run?
</h3>
<div className="mt-6 flex justify-center space-x-4">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-white text-gray-600 text-sm font-medium border rounded-md hover:bg-gray-50"
>
Retain
</button>
<button
onClick={async () => {
if (onDeleteRun) {
await onDeleteRun(run._id);
setShowDeleteConfirm(false);
}
}}
className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>