diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc167ed..4410326 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { } from "react-router-dom"; import { AppHeader } from "./components/AppHeader"; import { AssessmentPage } from "./components/AssessmentPage"; +import { BatchStatusPage } from "./components/BatchStatusPage"; import { CostAnalysisPanel } from "./components/CostAnalysisPanel"; import { EmptyState } from "./components/EmptyState"; import { EvaluationInputPanel } from "./components/EvaluationInputPanel"; @@ -653,6 +654,37 @@ function AppContent() { [api, navigate], ); + // Handle batch URL submission + const handleBatchSubmit = useCallback( + async ( + urls: string[], + _evaluators: number, + provider?: "claude" | "opencode" | "cursor" | "github-copilot", + evaluatorFilter?: EvaluatorFilter, + concurrency?: number, + ) => { + setApiError(null); + try { + const response = await api.submitBatch( + urls, + undefined, + provider, + evaluatorFilter, + undefined, + concurrency, + ); + navigate(`/batch/${response.batchId}`); + } catch (error) { + setApiError( + error instanceof Error + ? error.message + : "Failed to submit batch evaluation", + ); + } + }, + [api, navigate], + ); + // Handle job cancellation const handleCancelJob = useCallback(async () => { if (currentJobId) { @@ -1776,6 +1808,7 @@ function AppContent() {
@@ -1881,6 +1914,9 @@ function AppRoutes() { {assessmentEnabled && ( } /> )} + {!cloudMode && ( + } /> + )} ); } diff --git a/frontend/src/components/BatchStatusPage.tsx b/frontend/src/components/BatchStatusPage.tsx new file mode 100644 index 0000000..f7cfca1 --- /dev/null +++ b/frontend/src/components/BatchStatusPage.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useEvaluationApi } from "../hooks/useEvaluationApi"; +import { parseGitUrl } from "../lib/url-validation"; +import type { BatchEntryStatus, IBatchStatusResponse } from "../types/job"; + +const POLL_INTERVAL_MS = 3000; + +function getStatusBadge(status: BatchEntryStatus) { + switch (status) { + case "completed": + return ( + + + + + Completed + + ); + case "failed": + return ( + + + + + Failed + + ); + case "running": + return ( + + + + + Running + + ); + case "queued": + return ( + + Queued + + ); + case "pending": + return ( + + Pending + + ); + } +} + +function getRepoName(url: string): string { + const parsed = parseGitUrl(url); + if (parsed) { + return `${parsed.owner}/${parsed.repo}`; + } + // Fallback: extract last two path segments + try { + const urlObj = new URL(url); + const parts = urlObj.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; + } + } catch { + // ignore + } + return url; +} + +export function BatchStatusPage() { + const { batchId } = useParams<{ batchId: string }>(); + const api = useEvaluationApi(); + const [batchStatus, setBatchStatus] = useState(null); + const [loadError, setLoadError] = useState(null); + const pollRef = useRef | null>(null); + + const fetchStatus = useCallback(async () => { + if (!batchId) return; + try { + const status = await api.getBatchStatus(batchId); + setBatchStatus(status); + setLoadError(null); + + // Stop polling when finished + if (status.isFinished && pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } catch (err) { + setLoadError( + err instanceof Error ? err.message : "Failed to load batch status", + ); + } + }, [batchId, api]); + + // Initial fetch and polling + useEffect(() => { + fetchStatus(); + pollRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS); + + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + }, [fetchStatus]); + + if (loadError) { + return ( +
+
+
+ + + +
+

Error loading batch

+

{loadError}

+
+
+
+
+ ); + } + + if (!batchStatus) { + return ( +
+
+ + + + +

Loading batch status...

+
+
+ ); + } + + const completedOrFailed = batchStatus.completed + batchStatus.failed; + const progressPercent = + batchStatus.totalUrls > 0 + ? Math.round((completedOrFailed / batchStatus.totalUrls) * 100) + : 0; + + return ( +
+ {/* Header */} +
+
+ + + + + +

Batch Evaluation

+
+

+ {batchStatus.totalUrls} repositories —{" "} + {batchStatus.isFinished + ? `Finished (${batchStatus.completed} completed, ${batchStatus.failed} failed)` + : `Processing sequentially...`} +

+
+ + {/* Progress Bar */} +
+
+ + Overall Progress + + + {completedOrFailed} / {batchStatus.totalUrls} + +
+
+
+
+
+ + {/* Stats Row */} +
+
+

{batchStatus.completed}

+

Completed

+
+
+

{batchStatus.failed}

+

Failed

+
+
+

{batchStatus.running}

+

Running

+
+
+

{batchStatus.queued}

+

Queued

+
+
+

{batchStatus.pending}

+

Pending

+
+
+ + {/* Jobs List */} +
+
+

Repositories

+
+
+ {batchStatus.jobs.map((job, index) => ( +
+
+ + {index + 1} + +
+

+ {getRepoName(job.url)} +

+

+ {job.url} +

+
+
+
+ {getStatusBadge(job.status)} + {job.status === "completed" && ( + + View Results + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/EvaluationInputPanel.tsx b/frontend/src/components/EvaluationInputPanel.tsx index 2c31ecf..c39e816 100644 --- a/frontend/src/components/EvaluationInputPanel.tsx +++ b/frontend/src/components/EvaluationInputPanel.tsx @@ -11,6 +11,13 @@ interface IEvaluationInputPanelProps { evaluatorFilter: EvaluatorFilter, concurrency: number, ) => Promise; + onBatchSubmit?: ( + urls: string[], + evaluators: number, + provider: ProviderName, + evaluatorFilter: EvaluatorFilter, + concurrency: number, + ) => Promise; isLoading: boolean; urlError?: string | null; hasData: boolean; @@ -18,6 +25,7 @@ interface IEvaluationInputPanelProps { export const EvaluationInputPanel: React.FC = ({ onUrlSubmit, + onBatchSubmit, isLoading, urlError, hasData, @@ -31,6 +39,7 @@ export const EvaluationInputPanel: React.FC = ({
diff --git a/frontend/src/components/RepositoryUrlInput.tsx b/frontend/src/components/RepositoryUrlInput.tsx index 00c732b..7729673 100644 --- a/frontend/src/components/RepositoryUrlInput.tsx +++ b/frontend/src/components/RepositoryUrlInput.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFeatureFlags } from "../contexts/FeatureFlagContext"; import type { ProviderName } from "../hooks/useEvaluationApi"; import { useEvaluatorsApi } from "../hooks/useEvaluatorsApi"; @@ -7,6 +7,8 @@ import { isValidGitUrl } from "../lib/url-validation"; import type { EvaluatorFilter } from "../types/evaluation"; import type { IEvaluator } from "../types/evaluator"; +type InputMode = "single" | "batch"; + interface IRepositoryUrlInputProps { onSubmit: ( url: string, @@ -16,6 +18,13 @@ interface IRepositoryUrlInputProps { concurrency: number, selectedEvaluators?: string[], ) => Promise; + onBatchSubmit?: ( + urls: string[], + evaluators: number, + provider: ProviderName, + evaluatorFilter: EvaluatorFilter, + concurrency: number, + ) => Promise; isLoading: boolean; error?: string | null; disabled?: boolean; @@ -32,6 +41,7 @@ const PROVIDERS: { name: ProviderName; displayName: string }[] = [ export const RepositoryUrlInput: React.FC = ({ onSubmit, + onBatchSubmit, isLoading, error, disabled = false, @@ -44,6 +54,8 @@ export const RepositoryUrlInput: React.FC = ({ getProviderStatus, } = useProviderDetection(); const [url, setUrl] = useState(""); + const [batchText, setBatchText] = useState(""); + const [inputMode, setInputMode] = useState("single"); const [concurrency, setConcurrency] = useState(3); const [totalEvaluators, setTotalEvaluators] = useState(17); // Default fallback const [evaluatorsList, setEvaluatorsList] = useState([]); @@ -55,6 +67,23 @@ export const RepositoryUrlInput: React.FC = ({ const [provider, setProvider] = useState("claude"); const [validationError, setValidationError] = useState(null); + // Parse batch URLs from textarea + const parsedBatchUrls = useMemo(() => { + return batchText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }, [batchText]); + + const validBatchUrls = useMemo(() => { + return parsedBatchUrls.filter((u) => isValidGitUrl(u)); + }, [parsedBatchUrls]); + + const invalidBatchUrls = useMemo(() => { + return parsedBatchUrls.filter((u) => !isValidGitUrl(u)); + }, [parsedBatchUrls]); + + // Fetch evaluators list on mount to get the total count // Fetch evaluators list on mount to get the total count and details useEffect(() => { fetchEvaluatorsList() @@ -99,10 +128,47 @@ export const RepositoryUrlInput: React.FC = ({ return true; }, []); + const validateBatchUrls = useCallback((): boolean => { + if (parsedBatchUrls.length === 0) { + setValidationError("Please enter at least one repository URL"); + return false; + } + if (parsedBatchUrls.length > 50) { + setValidationError( + `Maximum 50 URLs allowed (${parsedBatchUrls.length} entered)`, + ); + return false; + } + if (invalidBatchUrls.length > 0) { + setValidationError( + `${invalidBatchUrls.length} invalid URL(s) found. Please fix them before submitting.`, + ); + return false; + } + setValidationError(null); + return true; + }, [parsedBatchUrls, invalidBatchUrls]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); + if (inputMode === "batch") { + if (!validateBatchUrls() || !onBatchSubmit) return; + try { + await onBatchSubmit( + validBatchUrls, + totalEvaluators, + provider, + evaluatorFilter, + concurrency, + ); + } catch { + // Error is handled by parent component + } + return; + } + if (!validateUrl(url)) { return; } @@ -129,13 +195,17 @@ export const RepositoryUrlInput: React.FC = ({ } }, [ + inputMode, url, + validBatchUrls, totalEvaluators, provider, selectedEvaluatorIds, concurrency, validateUrl, + validateBatchUrls, onSubmit, + onBatchSubmit, cloudMode, ], ); @@ -151,6 +221,16 @@ export const RepositoryUrlInput: React.FC = ({ [validationError], ); + const handleBatchTextChange = useCallback( + (e: React.ChangeEvent) => { + setBatchText(e.target.value); + if (validationError) { + setValidationError(null); + } + }, + [validationError], + ); + const displayError = validationError || error; /** @@ -270,6 +350,13 @@ export const RepositoryUrlInput: React.FC = ({ ); }; + // Determine if submit button should be enabled + const isSubmitDisabled = + disabled || + isLoading || + (inputMode === "single" && !url.trim()) || + (inputMode === "batch" && validBatchUrls.length === 0); + return (
@@ -278,47 +365,123 @@ export const RepositoryUrlInput: React.FC = ({ {/* Text Content */}

- Enter a Git repository URL to analyze + {inputMode === "batch" + ? "Enter Git repository URLs to analyze (one per line)" + : "Enter a Git repository URL to analyze"}

- {/* URL Input with focus glow */} -
-
-
- - - -
- + +
+ )} + + {/* URL Input / Batch Textarea */} +
+ {inputMode === "batch" ? ( + <> +