diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml new file mode 100644 index 00000000..731ac0f3 --- /dev/null +++ b/.github/workflows/cd-dev.yml @@ -0,0 +1,64 @@ +name: Deploy Kaapi Dev to EC2 + +on: + push: + branches: + - dev + +jobs: + deploy: + runs-on: ubuntu-latest + environment: AWS_ENV + + permissions: + packages: write + contents: read + attestations: write + id-token: write + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Deploy via SSM + id: ssm + env: + BUILD_DIRECTORY: ${{ secrets.DEV_BUILD_DIRECTORY }} + APP_NAME: ${{ secrets.DEV_PM2_APP_NAME }} + AWS_REGION: ${{ secrets.AWS_REGION }} + INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }} + ROOT_USER: ${{ secrets.USER }} + run: | + REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin dev && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}" + CMD_ID=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters commands="[\"$REMOTE_CMD\"]" \ + --region "$AWS_REGION" \ + --query 'Command.CommandId' \ + --output text) + echo "cmd_id=$CMD_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for SSM command to finish + env: + INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }} + CMD_ID: ${{ steps.ssm.outputs.cmd_id }} + run: | + WAIT_EXIT=0 + aws ssm wait command-executed \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" || WAIT_EXIT=$? + + aws ssm get-command-invocation \ + --command-id "$CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --query '{Status:Status,Stdout:StandardOutputContent,Stderr:StandardErrorContent}' \ + --output json + + exit $WAIT_EXIT diff --git a/app/(main)/assessment/page.tsx b/app/(main)/assessment/page.tsx new file mode 100644 index 00000000..e8e295bb --- /dev/null +++ b/app/(main)/assessment/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Suspense } from "react"; +import { Loader } from "@/app/components/ui"; +import PageLayout from "@/app/components/assessment/PageLayout"; +import { useAssessmentWorkflow } from "@/app/hooks/useAssessmentWorkflow"; + +function PageContent() { + const layoutProps = useAssessmentWorkflow(); + return ; +} + +export default function Page() { + return ( + }> + + + ); +} diff --git a/app/(main)/assessment/results/[runId]/page.tsx b/app/(main)/assessment/results/[runId]/page.tsx new file mode 100644 index 00000000..12242bc2 --- /dev/null +++ b/app/(main)/assessment/results/[runId]/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import dynamic from "next/dynamic"; +import { Loader } from "@/app/components/ui"; +import { useToast } from "@/app/hooks/useToast"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { jsonResultsToTableData } from "@/app/lib/assessment/results"; +import { SPREADSHEET_PREVIEW_ROW_LIMIT } from "@/app/lib/assessment/constants"; + +const SpreadsheetView = dynamic( + () => import("@/app/components/assessment/SpreadsheetView"), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + +export default function AssessmentResultsPage() { + const params = useParams<{ runId: string }>(); + const searchParams = useSearchParams(); + const toast = useToast(); + const { apiKeys, isAuthenticated, isHydrated } = useAuth(); + const apiKey = apiKeys[0]?.key ?? ""; + + const [headers, setHeaders] = useState(null); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + const runId = Number(params?.runId); + const title = searchParams.get("title") ?? `Run ${runId}`; + + useEffect(() => { + if (!isHydrated) return; + if (!isAuthenticated) { + setError("You must be signed in to view this run."); + return; + } + if (!Number.isFinite(runId) || runId <= 0) { + setError("Invalid run id."); + return; + } + + let cancelled = false; + (async () => { + try { + const json = await apiFetch< + { data?: Record[] } | Record[] + >(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey); + const results: Record[] = Array.isArray(json) + ? json + : json.data || []; + const table = jsonResultsToTableData(results, { + rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT, + }); + if (cancelled) return; + if (results.length > SPREADSHEET_PREVIEW_ROW_LIMIT) { + toast.warning( + `Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`, + ); + } + setHeaders(table.headers); + setRows(table.rows); + } catch (err) { + if (cancelled) return; + const msg = + err instanceof Error ? err.message : "Failed to load results"; + setError(msg); + toast.error(msg); + } + })(); + + return () => { + cancelled = true; + }; + }, [apiKey, isAuthenticated, isHydrated, runId, toast]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!headers || !rows) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index 4eab892e..35ac0d98 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -24,7 +24,7 @@ import { configState, pendingVersionLoads, pendingSingleVersionLoads, -} from "@/app/lib/store/configStore"; +} from "@/app/lib/store/config"; import { flattenConfigVersion } from "@/app/lib/utils"; import { SearchIcon, diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 7ecbe214..1be02416 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -21,7 +21,7 @@ import { useAuth } from "@/app/lib/context/AuthContext"; import { useConfigs } from "@/app/hooks"; import { useConfigPersistence } from "@/app/hooks/useConfigPersistence"; import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs"; -import { configState } from "@/app/lib/store/configStore"; +import { configState } from "@/app/lib/store/config"; import { DEFAULT_CONFIG } from "@/app/lib/constants"; function PromptEditorContent() { diff --git a/app/(main)/datasets/page.tsx b/app/(main)/datasets/page.tsx index 7f1266e9..16e1227d 100644 --- a/app/(main)/datasets/page.tsx +++ b/app/(main)/datasets/page.tsx @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from "react"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; +import { Dataset } from "@/app/lib/types/dataset"; import { apiFetch } from "@/app/lib/apiClient"; import Sidebar from "@/app/components/Sidebar"; import { PageHeader } from "@/app/components"; @@ -15,7 +16,6 @@ import { useToast } from "@/app/hooks/useToast"; import DatasetListing from "@/app/components/datasets/DatasetListing"; import UploadDatasetModal from "@/app/components/datasets/UploadDatasetModal"; import DeleteDatasetModal from "@/app/components/datasets/DeleteDatasetModal"; -import { Dataset } from "@/app/lib/types/dataset"; export const DATASETS_STORAGE_KEY = "kaapi_datasets"; diff --git a/app/(main)/keystore/page.tsx b/app/(main)/keystore/page.tsx index 3f343a3a..cafc6ef7 100644 --- a/app/(main)/keystore/page.tsx +++ b/app/(main)/keystore/page.tsx @@ -16,8 +16,6 @@ import { useToast } from "@/app/hooks/useToast"; import { apiFetch } from "@/app/lib/apiClient"; import { APIKey } from "@/app/lib/types/credentials"; -export const STORAGE_KEY = "kaapi_api_keys"; - export default function KaapiKeystore() { const { sidebarCollapsed } = useApp(); const { apiKeys, addKey, removeKey: removeApiKey } = useAuth(); diff --git a/app/api/_routeProxy.ts b/app/api/_routeProxy.ts new file mode 100644 index 00000000..f9ca0d63 --- /dev/null +++ b/app/api/_routeProxy.ts @@ -0,0 +1,93 @@ +import "server-only"; + +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +const DOWNLOAD_CONTENT_TYPE_HINTS = [ + "text/csv", + "spreadsheetml", + "octet-stream", + "application/zip", +]; + +function isDownloadContentType(contentType: string): boolean { + return DOWNLOAD_CONTENT_TYPE_HINTS.some((hint) => contentType.includes(hint)); +} + +async function safeParseJson( + response: Response, +): Promise | unknown[] | null> { + const text = response.status === 204 ? "" : await response.text(); + if (!text) return null; + + try { + return JSON.parse(text) as Record | unknown[]; + } catch { + return null; + } +} + +async function toDownloadResponse( + response: Response, +): Promise { + const contentType = response.headers.get("content-type") || ""; + if (!isDownloadContentType(contentType)) { + return null; + } + + const blob = await response.blob(); + const headers = new Headers(); + headers.set("Content-Type", contentType); + + const disposition = response.headers.get("content-disposition"); + if (disposition) { + headers.set("Content-Disposition", disposition); + } + + return new NextResponse(blob, { status: response.status, headers }); +} + +export function withQueryParams( + endpoint: string, + queryParams: URLSearchParams, +): string { + const query = queryParams.toString(); + return query ? `${endpoint}?${query}` : endpoint; +} + +export async function proxyJsonResponse( + request: Request, + endpoint: string, + init: RequestInit = {}, +): Promise { + const { status, data } = await apiClient(request, endpoint, init); + return NextResponse.json(data, { status }); +} + +export async function proxyDownloadOrJsonResponse( + request: Request, + endpoint: string, + init: RequestInit = {}, +): Promise { + const response = await apiClient(request, endpoint, { + ...init, + responseType: "raw", + }); + + const downloadResponse = await toDownloadResponse(response); + if (downloadResponse) { + return downloadResponse; + } + + const data = await safeParseJson(response); + return NextResponse.json(data, { status: response.status }); +} + +export function proxyErrorResponse( + logLabel: string, + error: unknown, + message = "Failed to forward request to backend", +): NextResponse { + console.error(logLabel, error); + return NextResponse.json({ error: message }, { status: 500 }); +} diff --git a/app/api/assessment/assessments/[assessment_id]/results/route.ts b/app/api/assessment/assessments/[assessment_id]/results/route.ts new file mode 100644 index 00000000..4d376399 --- /dev/null +++ b/app/api/assessment/assessments/[assessment_id]/results/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { + proxyDownloadOrJsonResponse, + proxyErrorResponse, + withQueryParams, +} from "@/app/api/_routeProxy"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ assessment_id: string }> }, +) { + try { + const { assessment_id } = await params; + const queryParams = new URLSearchParams(request.nextUrl.searchParams); + queryParams.set("get_trace_info", "true"); + return await proxyDownloadOrJsonResponse( + request, + withQueryParams( + `/api/v1/assessment/assessments/${assessment_id}/results`, + queryParams, + ), + { method: "GET" }, + ); + } catch (error: unknown) { + return proxyErrorResponse( + "Assessment results proxy error:", + error, + "Failed to forward request", + ); + } +} diff --git a/app/api/assessment/assessments/[assessment_id]/retry/route.ts b/app/api/assessment/assessments/[assessment_id]/retry/route.ts new file mode 100644 index 00000000..17a2ecc0 --- /dev/null +++ b/app/api/assessment/assessments/[assessment_id]/retry/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import type { RouteContext } from "@/app/lib/types/assessment"; + +export async function POST( + request: NextRequest, + context: RouteContext<"assessment_id">, +) { + try { + const { assessment_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/assessments/${assessment_id}/retry`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment retry proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward assessment retry request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/assessments/route.ts b/app/api/assessment/assessments/route.ts new file mode 100644 index 00000000..63b61661 --- /dev/null +++ b/app/api/assessment/assessments/route.ts @@ -0,0 +1,22 @@ +import { NextRequest } from "next/server"; +import { + proxyErrorResponse, + proxyJsonResponse, + withQueryParams, +} from "@/app/api/_routeProxy"; + +export async function GET(request: NextRequest) { + try { + const queryParams = new URLSearchParams(request.nextUrl.searchParams); + queryParams.set("get_trace_info", "true"); + return await proxyJsonResponse( + request, + withQueryParams("/api/v1/assessment/assessments", queryParams), + { + method: "GET", + }, + ); + } catch (error: unknown) { + return proxyErrorResponse("Assessment list proxy error:", error); + } +} diff --git a/app/api/assessment/datasets/[dataset_id]/route.ts b/app/api/assessment/datasets/[dataset_id]/route.ts new file mode 100644 index 00000000..240eec59 --- /dev/null +++ b/app/api/assessment/datasets/[dataset_id]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import { proxyErrorResponse, withQueryParams } from "@/app/api/_routeProxy"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ dataset_id: string }> }, +) { + try { + const { dataset_id } = await params; + const limitRows = request.nextUrl.searchParams.get("limit_rows"); + + const backendParams = new URLSearchParams(); + if (limitRows) { + backendParams.set("limit_rows", limitRows); + } + const endpoint = withQueryParams( + `/api/v1/assessment/datasets/${dataset_id}`, + backendParams, + ); + + const { status, data } = await apiClient(request, endpoint, { + method: "GET", + }); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return proxyErrorResponse("Assessment dataset details proxy error:", error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ dataset_id: string }> }, +) { + try { + const { dataset_id } = await params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/datasets/${dataset_id}`, + { method: "DELETE" }, + ); + + if (status === 204) { + return new NextResponse(null, { status }); + } + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return proxyErrorResponse("Assessment dataset delete proxy error:", error); + } +} diff --git a/app/api/assessment/datasets/route.ts b/app/api/assessment/datasets/route.ts new file mode 100644 index 00000000..dcda022f --- /dev/null +++ b/app/api/assessment/datasets/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from "next/server"; +import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy"; + +export async function GET(request: NextRequest) { + try { + return await proxyJsonResponse(request, "/api/v1/assessment/datasets", { + method: "GET", + }); + } catch (error: unknown) { + return proxyErrorResponse("Assessment datasets list proxy error:", error); + } +} + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + return await proxyJsonResponse(request, "/api/v1/assessment/datasets", { + method: "POST", + body: formData, + }); + } catch (error: unknown) { + return proxyErrorResponse("Assessment datasets create proxy error:", error); + } +} diff --git a/app/api/assessment/runs/[run_id]/post-processing/route.ts b/app/api/assessment/runs/[run_id]/post-processing/route.ts new file mode 100644 index 00000000..213d60e6 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/post-processing/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; +import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy"; +import type { RouteContext } from "@/app/lib/types/assessment"; + +export async function PATCH( + request: NextRequest, + context: RouteContext<"run_id">, +) { + try { + const { run_id } = await context.params; + const body = await request.json(); + return await proxyJsonResponse( + request, + `/api/v1/assessment/runs/${run_id}/post-processing`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ); + } catch (error: unknown) { + return proxyErrorResponse( + "Assessment run post-processing proxy error:", + error, + ); + } +} diff --git a/app/api/assessment/runs/[run_id]/results/route.ts b/app/api/assessment/runs/[run_id]/results/route.ts new file mode 100644 index 00000000..a9135c21 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/results/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from "next/server"; +import { + proxyDownloadOrJsonResponse, + proxyErrorResponse, + withQueryParams, +} from "@/app/api/_routeProxy"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ run_id: string }> }, +) { + try { + const { run_id } = await params; + const queryParams = new URLSearchParams(request.nextUrl.searchParams); + queryParams.set("get_trace_info", "true"); + const endpoint = withQueryParams( + `/api/v1/assessment/runs/${run_id}/results`, + queryParams, + ); + return await proxyDownloadOrJsonResponse(request, endpoint, { + method: "GET", + }); + } catch (error: unknown) { + return proxyErrorResponse( + "Assessment run results proxy error:", + error, + "Failed to forward request", + ); + } +} diff --git a/app/api/assessment/runs/[run_id]/resume/route.ts b/app/api/assessment/runs/[run_id]/resume/route.ts new file mode 100644 index 00000000..63a5ff13 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/resume/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import type { RouteContext } from "@/app/lib/types/assessment"; + +export async function POST( + request: NextRequest, + context: RouteContext<"run_id">, +) { + try { + const { run_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/runs/${run_id}/resume`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment run resume proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward assessment run resume request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/runs/[run_id]/retry/route.ts b/app/api/assessment/runs/[run_id]/retry/route.ts new file mode 100644 index 00000000..d38614d7 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/retry/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import type { RouteContext } from "@/app/lib/types/assessment"; + +export async function POST( + request: NextRequest, + context: RouteContext<"run_id">, +) { + try { + const { run_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/runs/${run_id}/retry`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment run retry proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward assessment run retry request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/runs/[run_id]/route.ts b/app/api/assessment/runs/[run_id]/route.ts new file mode 100644 index 00000000..55a4a423 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from "next/server"; +import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ run_id: string }> }, +) { + try { + const { run_id } = await params; + return await proxyJsonResponse( + request, + `/api/v1/assessment/runs/${run_id}`, + { + method: "GET", + }, + ); + } catch (error: unknown) { + return proxyErrorResponse("Assessment run proxy error:", error); + } +} diff --git a/app/api/assessment/runs/route.ts b/app/api/assessment/runs/route.ts new file mode 100644 index 00000000..0463b773 --- /dev/null +++ b/app/api/assessment/runs/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import { + proxyErrorResponse, + proxyJsonResponse, + withQueryParams, +} from "@/app/api/_routeProxy"; + +export async function GET(request: NextRequest) { + try { + const queryParams = new URLSearchParams(request.nextUrl.searchParams); + return await proxyJsonResponse( + request, + withQueryParams("/api/v1/assessment/runs", queryParams), + { + method: "GET", + }, + ); + } catch (error: unknown) { + return proxyErrorResponse("Assessment runs list proxy error:", error); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + return await proxyJsonResponse(request, "/api/v1/assessment/runs", { + method: "POST", + body: JSON.stringify(body), + }); + } catch (error: unknown) { + return proxyErrorResponse("Assessment runs create proxy error:", error); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 5111c38a..8461a4fe 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; -import { clearRoleCookie } from "@/app/lib/authCookie"; +import { clearFeaturesCookie, clearRoleCookie } from "@/app/lib/authCookie"; export async function POST(request: NextRequest) { const { status, data, headers } = await apiClient( @@ -17,6 +17,7 @@ export async function POST(request: NextRequest) { } clearRoleCookie(res); + clearFeaturesCookie(res); return res; } diff --git a/app/api/configs/[config_id]/route.ts b/app/api/configs/[config_id]/route.ts index 0a5c60cc..b90f9641 100644 --- a/app/api/configs/[config_id]/route.ts +++ b/app/api/configs/[config_id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { withQueryParams } from "@/app/api/_routeProxy"; import { apiClient } from "@/app/lib/apiClient"; export async function GET( @@ -8,9 +9,10 @@ export async function GET( const { config_id } = await params; try { + const { searchParams } = new URL(request.url); const { status, data } = await apiClient( request, - `/api/v1/configs/${config_id}`, + withQueryParams(`/api/v1/configs/${config_id}`, searchParams), ); return NextResponse.json(data, { status }); } catch (_error) { diff --git a/app/api/configs/[config_id]/versions/[version_number]/route.ts b/app/api/configs/[config_id]/versions/[version_number]/route.ts index 4d89bd0e..d879abde 100644 --- a/app/api/configs/[config_id]/versions/[version_number]/route.ts +++ b/app/api/configs/[config_id]/versions/[version_number]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { withQueryParams } from "@/app/api/_routeProxy"; import { apiClient } from "@/app/lib/apiClient"; export async function GET( @@ -10,9 +11,13 @@ export async function GET( const { config_id, version_number } = await params; try { + const { searchParams } = new URL(request.url); const { status, data } = await apiClient( request, - `/api/v1/configs/${config_id}/versions/${version_number}`, + withQueryParams( + `/api/v1/configs/${config_id}/versions/${version_number}`, + searchParams, + ), ); return NextResponse.json(data, { status }); } catch (_error) { diff --git a/app/api/configs/[config_id]/versions/route.ts b/app/api/configs/[config_id]/versions/route.ts index 9ac697d7..566b2a88 100644 --- a/app/api/configs/[config_id]/versions/route.ts +++ b/app/api/configs/[config_id]/versions/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { withQueryParams } from "@/app/api/_routeProxy"; import { apiClient } from "@/app/lib/apiClient"; export async function GET( @@ -8,10 +9,13 @@ export async function GET( const { config_id } = await params; try { - const { status, data } = await apiClient( - request, + const { searchParams } = new URL(request.url); + const endpoint = withQueryParams( `/api/v1/configs/${config_id}/versions`, + searchParams, ); + const { status, data } = await apiClient(request, endpoint); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( @@ -29,15 +33,15 @@ export async function POST( try { const body = await request.json(); - - const { status, data } = await apiClient( - request, + const { searchParams } = new URL(request.url); + const endpoint = withQueryParams( `/api/v1/configs/${config_id}/versions`, - { - method: "POST", - body: JSON.stringify(body), - }, + searchParams, ); + const { status, data } = await apiClient(request, endpoint, { + method: "POST", + body: JSON.stringify(body), + }); return NextResponse.json(data, { status }); } catch (_error) { diff --git a/app/api/evaluations/datasets/[dataset_id]/route.ts b/app/api/evaluations/datasets/[dataset_id]/route.ts index 00561cca..494d4b27 100644 --- a/app/api/evaluations/datasets/[dataset_id]/route.ts +++ b/app/api/evaluations/datasets/[dataset_id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; +import type { DatasetDetailsPayload } from "@/app/lib/types/dataset"; /** * GET /api/evaluations/datasets/:dataset_id @@ -15,7 +16,7 @@ export async function GET( const searchParams = request.nextUrl.searchParams.toString(); const queryString = searchParams ? `?${searchParams}` : ""; - const { status, data } = await apiClient( + const { status, data } = await apiClient( request, `/api/v1/evaluations/datasets/${dataset_id}${queryString}`, ); @@ -43,7 +44,7 @@ export async function GET( } const csvText = await csvResponse.text(); return NextResponse.json( - { ...data, csv_content: csvText }, + { ...(data ?? {}), csv_content: csvText }, { status: 200 }, ); } diff --git a/app/api/evaluations/tts/datasets/[dataset_id]/route.ts b/app/api/evaluations/tts/datasets/[dataset_id]/route.ts index 05e70d66..20f3e9de 100644 --- a/app/api/evaluations/tts/datasets/[dataset_id]/route.ts +++ b/app/api/evaluations/tts/datasets/[dataset_id]/route.ts @@ -1,5 +1,6 @@ import { apiClient } from "@/app/lib/apiClient"; import { NextResponse } from "next/server"; +import type { DatasetDetailsPayload } from "@/app/lib/types/dataset"; export async function GET( request: Request, @@ -18,7 +19,7 @@ export async function GET( ? `?${backendParams.toString()}` : ""; - const { data, status } = await apiClient( + const { data, status } = await apiClient( request, `/api/v1/evaluations/tts/datasets/${dataset_id}${queryString}`, ); @@ -42,7 +43,7 @@ export async function GET( } const csvText = await csvResponse.text(); return NextResponse.json( - { ...data, csv_content: csvText }, + { ...(data ?? {}), csv_content: csvText }, { status: 200 }, ); } diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index 6b9fe0f2..32686d75 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; -import { setRoleCookieFromBody } from "@/app/lib/authCookie"; +import { + setFeaturesCookieFromBody, + setRoleCookieFromBody, +} from "@/app/lib/authCookie"; export async function GET(request: NextRequest) { try { @@ -9,6 +12,7 @@ export async function GET(request: NextRequest) { if (status >= 200 && status < 300) { setRoleCookieFromBody(res, data); + setFeaturesCookieFromBody(res, data); } return res; diff --git a/app/components/ConfigCard.tsx b/app/components/ConfigCard.tsx index 87197888..ad2a0991 100644 --- a/app/components/ConfigCard.tsx +++ b/app/components/ConfigCard.tsx @@ -94,7 +94,7 @@ export default function ConfigCard({ try { await onLoadVersions(config.id); - const { configState } = await import("@/app/lib/store/configStore"); + const { configState } = await import("@/app/lib/store/config"); const versionItems = configState.versionItemsCache[config.id]; if (!versionItems || versionItems.length === 0) { setIsLoadingDetails(false); diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 34029af7..6cc7d447 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -4,29 +4,29 @@ */ "use client"; - import React, { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; import { + AssessmentIcon, + BookOpenIcon, + ChevronRightIcon, + ChevronLeftIcon, ClipboardIcon, DocumentFileIcon, - BookOpenIcon, GearIcon, - SlidersIcon, ShieldCheckIcon, - ChevronRightIcon, - ChevronLeftIcon, + SlidersIcon, ChatIcon, ChartBarIcon, } from "@/app/components/icons"; +import { MenuItem, SidebarProps } from "@/app/lib/types/nav"; import { LoginModal } from "@/app/components/auth"; import { Branding, UserMenuPopover } from "@/app/components/user-menu"; import GatePopover from "@/app/components/GatePopover"; import { NAV_ITEMS } from "@/app/lib/navConfig"; -import { MenuItem, SidebarProps } from "@/app/lib/types/nav"; const PUBLIC_ROUTES = new Set(["/", "/chat"]); @@ -35,7 +35,9 @@ export default function Sidebar({ activeRoute = "/chat", }: SidebarProps) { const router = useRouter(); - const { currentUser, googleProfile, isAuthenticated, logout } = useAuth(); + const [hasMounted, setHasMounted] = useState(false); + const { currentUser, googleProfile, isAuthenticated, logout, hasFeature } = + useAuth(); const { setSidebarCollapsed } = useApp(); const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, @@ -48,6 +50,10 @@ export default function Sidebar({ const [gateRect, setGateRect] = useState(null); const gateTimeoutRef = useRef | null>(null); + useEffect(() => { + setHasMounted(true); + }, []); + useEffect(() => { const saved = localStorage.getItem("sidebar-expanded-menus"); if (saved) { @@ -120,17 +126,24 @@ export default function Sidebar({ gear: , shield: , sliders: , + assessment: , chart: , }; - const navItems: MenuItem[] = NAV_ITEMS.filter( - (item) => !item.superuserOnly || currentUser?.is_superuser, - ).map((item) => ({ + const navItems: MenuItem[] = NAV_ITEMS.filter((item) => { + if (item.superuserOnly && !currentUser?.is_superuser) return false; + if (item.featureFlag) { + if (!hasMounted) return false; + if (!hasFeature(item.featureFlag)) return false; + } + return true; + }).map((item) => ({ name: item.name, route: item.route, icon: iconMap[item.icon], submenu: item.submenu, gateDescription: item.gateDescription, + featureFlag: item.featureFlag, })); const getGateDescription = (name: string): string => { diff --git a/app/components/assessment/AssessmentChildRunCard.tsx b/app/components/assessment/AssessmentChildRunCard.tsx new file mode 100644 index 00000000..2faa07d1 --- /dev/null +++ b/app/components/assessment/AssessmentChildRunCard.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useToast } from "@/app/hooks/useToast"; +import PostProcessingPanel from "./PostProcessingPanel"; +import ChildRunStageProgress from "./ChildRunStageProgress"; +import ChildRunActions from "./ChildRunActions"; +import { + getStageProgress, + isCompletedStatus, + isFailedStatus, +} from "@/app/lib/assessment/results"; +import { formatRelativeTime } from "@/app/lib/utils"; +import type { + AssessmentChildRun, + ConfigRunDetail, + ExportFormat, + PostProcessingConfig, +} from "@/app/lib/types/assessment"; + +interface AssessmentChildRunCardProps { + childRun: AssessmentChildRun; + configDetailsByKey: Record; + configLoadingKeys: Record; + configErrorKeys: Record; + rerunningId: number | null; + resumingId: number | null; + downloadingId: string | null; + onPreview: (runId: number, label: string) => void; + onRunDownload: (runId: number, format: ExportFormat) => void; + onResume: (run: AssessmentChildRun) => void; + onRerun: (run: AssessmentChildRun) => void; + onFetchRunColumns: (runId: number) => Promise; + onSavePostProcessing: ( + runId: number, + config: PostProcessingConfig | null, + ) => Promise; +} + +export default function AssessmentChildRunCard({ + childRun, + configDetailsByKey, + configLoadingKeys, + configErrorKeys, + rerunningId, + resumingId, + downloadingId, + onPreview, + onRunDownload, + onResume, + onRerun, + onFetchRunColumns, + onSavePostProcessing, +}: AssessmentChildRunCardProps) { + const toast = useToast(); + + const isFailedChild = isFailedStatus(childRun.status); + const isCompletedChild = isCompletedStatus(childRun.status); + const stageProgress = getStageProgress(childRun); + const isRerunning = rerunningId === childRun.id; + const isResuming = resumingId === childRun.id; + const configKey = + childRun.config_id && childRun.config_version + ? `${childRun.config_id}:${childRun.config_version}` + : null; + const configDetail = configKey ? configDetailsByKey[configKey] : null; + const isConfigLoading = configKey + ? Boolean(configLoadingKeys[configKey]) + : false; + const configError = configKey ? configErrorKeys[configKey] : null; + const fallbackName = childRun.config_id + ? `Config ${childRun.config_id.slice(0, 8)}` + : "Configuration"; + const configName = configDetail?.name || fallbackName; + const previewLabel = `${configName}${childRun.config_version ? ` v${childRun.config_version}` : ""}`; + + return ( +
+
+
+
+
+ + {configName} + + {childRun.config_version !== null && ( + + v{childRun.config_version} + + )} + {configDetail?.provider && configDetail?.model && ( + + {configDetail.provider}/{configDetail.model} + + )} +
+ +
+ {isConfigLoading + ? "Loading configuration details..." + : configDetail?.description || + configDetail?.commitMessage || + "No description available for this configuration."} +
+ +
+ {childRun.total_items} items + {childRun.updated_at && ( + {formatRelativeTime(childRun.updated_at)} + )} + {childRun.config_id && ( + + ID {childRun.config_id.slice(0, 8)} + + )} +
+ {childRun.prefilter_total_rows != null && ( +
+ Prefilter: {childRun.prefilter_total_passed ?? 0}/ + {childRun.prefilter_total_rows} passed + {childRun.prefilter_total_rejected != null && + childRun.prefilter_total_rejected > 0 && ( + + · {childRun.prefilter_total_rejected} rejected + + )} +
+ )} + + + + {configError && ( +
+ {configError} +
+ )} + {isFailedChild && childRun.error_message && ( +
+ {childRun.error_message} +
+ )} +
+ + +
+
+ {isCompletedChild && ( + onFetchRunColumns(childRun.id)} + onSave={async (cfg: PostProcessingConfig) => { + await onSavePostProcessing(childRun.id, cfg); + toast.success( + "Post-processing saved. Re-open preview to see updated results.", + ); + }} + /> + )} +
+ ); +} diff --git a/app/components/assessment/ChildRunActions.tsx b/app/components/assessment/ChildRunActions.tsx new file mode 100644 index 00000000..53289a49 --- /dev/null +++ b/app/components/assessment/ChildRunActions.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Button } from "@/app/components/ui"; +import { EyeIcon } from "@/app/components/icons"; +import DownloadDropdown from "./DownloadDropdown"; +import { + formatStatusLabel, + getResultTone, + hasViewableResults, + isCompletedStatus, + isFailedStatus, +} from "@/app/lib/assessment/results"; +import { STATUS_BADGE_CLASSES } from "@/app/lib/assessment/constants"; +import type { + AssessmentChildRun, + ExportFormat, +} from "@/app/lib/types/assessment"; + +interface ChildRunActionsProps { + childRun: AssessmentChildRun; + previewLabel: string; + isResuming: boolean; + isRerunning: boolean; + downloadingId: string | null; + onPreview: (runId: number, label: string) => void; + onRunDownload: (runId: number, format: ExportFormat) => void; + onResume: (run: AssessmentChildRun) => void; + onRerun: (run: AssessmentChildRun) => void; +} + +export default function ChildRunActions({ + childRun, + previewLabel, + isResuming, + isRerunning, + downloadingId, + onPreview, + onRunDownload, + onResume, + onRerun, +}: ChildRunActionsProps) { + const childStatusClass = STATUS_BADGE_CLASSES[getResultTone(childRun.status)]; + const isFailedChild = isFailedStatus(childRun.status); + const isCompletedChild = isCompletedStatus(childRun.status); + const canPreview = hasViewableResults(childRun); + const canResume = Boolean(childRun.stage); + + return ( +
+ + {formatStatusLabel(childRun.status)} + + {canPreview && ( + + )} + {isCompletedChild && ( + onRunDownload(childRun.id, fmt)} + loading={downloadingId === `run-${childRun.id}`} + /> + )} + {isFailedChild && canResume && ( + + )} + {isFailedChild && ( + + )} +
+ ); +} diff --git a/app/components/assessment/ChildRunStageProgress.tsx b/app/components/assessment/ChildRunStageProgress.tsx new file mode 100644 index 00000000..d2c8731a --- /dev/null +++ b/app/components/assessment/ChildRunStageProgress.tsx @@ -0,0 +1,67 @@ +import { Fragment } from "react"; +import type { + StageProgress, + StageProgressStatus, +} from "@/app/lib/assessment/results"; + +interface ChildRunStageProgressProps { + stages: StageProgress[]; +} + +const nodeClass: Record = { + completed: "bg-status-success border-status-success text-white", + failed: "bg-status-error border-status-error text-white", + processing: "border-status-warning text-status-warning-text", + pending: "border-border text-text-secondary", +}; + +const labelClass: Record = { + completed: "text-text-primary", + processing: "text-status-warning-text", + failed: "text-status-error-text", + pending: "text-text-secondary", +}; + +const nodeSymbol: Record = { + completed: "✓", + failed: "✗", + processing: "●", + pending: "", +}; + +export default function ChildRunStageProgress({ + stages, +}: ChildRunStageProgressProps) { + if (stages.length === 0) return null; + + return ( +
+ {stages.map((s, i) => { + const prevDone = i > 0 && stages[i - 1].status === "completed"; + return ( + + {i > 0 && ( +
+ )} +
+ + {nodeSymbol[s.status]} + + + {s.label} + +
+ + ); + })} +
+ ); +} diff --git a/app/components/assessment/ColumnMapperStep.tsx b/app/components/assessment/ColumnMapperStep.tsx new file mode 100644 index 00000000..497117a6 --- /dev/null +++ b/app/components/assessment/ColumnMapperStep.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Select } from "@/app/components/ui"; +import { + ASSESSMENT_ROLE_OPTION_MAP, + ASSESSMENT_ROLE_OPTIONS, + ATTACHMENT_FORMATS, +} from "@/app/lib/assessment/constants"; +import type { + Attachment, + ColumnConfig, + ColumnRole, + ColumnMapperStepProps, +} from "@/app/lib/types/assessment"; +import { buildColumnConfigs, colorMapping } from "@/app/lib/utils/assessment"; + +export default function ColumnMapperStep({ + columns, + columnMapping, + setColumnMapping, + onNext, + onBack, +}: ColumnMapperStepProps) { + const [columnConfigs, setColumnConfigs] = useState(() => + buildColumnConfigs(columns, columnMapping), + ); + + useEffect(() => { + setColumnConfigs(buildColumnConfigs(columns, columnMapping)); + }, [columns, columnMapping]); + + const updateRole = (index: number, role: ColumnRole) => { + if (role === "ground_truth") { + return; + } + + setColumnConfigs((prev) => { + const current = prev[index]; + const next = [...prev]; + + if (role !== "attachment") { + next[index] = { role }; + return next; + } + + next[index] = { + role, + attachmentType: current?.attachmentType || "mixed", + attachmentFormat: current?.attachmentFormat || "url", + }; + return next; + }); + }; + + const updateAttachmentType = ( + index: number, + type: "image" | "pdf" | "mixed", + ) => { + setColumnConfigs((prev) => { + const next = [...prev]; + next[index] = { + ...prev[index], + role: "attachment", + attachmentType: type, + attachmentFormat: "url", + }; + return next; + }); + }; + + const updateAttachmentFormat = (index: number, format: string) => { + setColumnConfigs((prev) => { + const next = [...prev]; + next[index] = { + ...prev[index], + role: "attachment", + attachmentFormat: format, + }; + return next; + }); + }; + + const patchAttachment = (index: number, patch: Partial) => { + setColumnConfigs((prev) => { + const next = [...prev]; + next[index] = { ...prev[index], role: "attachment", ...patch }; + return next; + }); + }; + + const handleNext = () => { + const textColumns: string[] = []; + const attachments: Attachment[] = []; + + columnConfigs.forEach((config, index) => { + const column = columns[index]; + if (!column) return; + + if (config.role === "text") { + textColumns.push(column); + } else if ( + config.role === "attachment" && + config.attachmentType && + config.attachmentFormat + ) { + const attachment: Attachment = { + column, + type: config.attachmentType, + format: config.attachmentFormat as Attachment["format"], + }; + if (config.attachmentType === "mixed" && config.attachmentTypeColumn) { + const map: Record = {}; + const split = (s?: string) => + (s || "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + split(config.attachmentImageValues).forEach( + (v) => (map[v] = "image"), + ); + split(config.attachmentPdfValues).forEach((v) => (map[v] = "pdf")); + if (Object.keys(map).length > 0) { + attachment.type_column = config.attachmentTypeColumn; + attachment.type_value_map = map; + } + } + attachments.push(attachment); + } + }); + + setColumnMapping({ textColumns, attachments, groundTruthColumns: [] }); + onNext(); + }; + + const mappedCount = columnConfigs.filter( + (config) => config.role !== "unmapped", + ).length; + const hasMappedColumn = columnConfigs.some( + (config) => config.role === "text" || config.role === "attachment", + ); + + return ( +
+
+
+
+

+ Map Columns +

+

+ Choose a role for each column. +

+
+
+ {mappedCount}/{columns.length} mapped +
+
+ + {columns.length === 0 ? ( +
+

+ No columns found. +

+

+ Go back and select a dataset first. +

+
+ ) : ( +
+ {columns.map((column, index) => { + const config = columnConfigs[index] || { + role: "unmapped" as ColumnRole, + }; + const activeOption = + ASSESSMENT_ROLE_OPTION_MAP[config.role] || + ASSESSMENT_ROLE_OPTION_MAP.unmapped; + const roleVisuals = colorMapping(activeOption.value); + + return ( +
+
+
+
+
+ + + {column} + +
+
+ +
+ {ASSESSMENT_ROLE_OPTIONS.map((option) => { + const isGroundTruth = option.value === "ground_truth"; + const isActive = config.role === option.value; + return ( + + ); + })} +
+
+ + {config.role === "attachment" && ( + <> +
+ +
+ + {(config.attachmentType || "mixed") === "mixed" && ( +
+ + Mixed: pick a column whose value tells each + row's type, then list which values mean image + vs PDF. + + + +
+ )} +
+ )} + + )} +
+
+ ); + })} +
+ )} +
+ +
+
+ + +
+ + {hasMappedColumn + ? "Ready to continue." + : "Map at least one Text or Attachment column."} + + +
+
+
+
+ ); +} diff --git a/app/components/assessment/CompactToggleSwitch.tsx b/app/components/assessment/CompactToggleSwitch.tsx new file mode 100644 index 00000000..0df8bc21 --- /dev/null +++ b/app/components/assessment/CompactToggleSwitch.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ToggleOffIcon, ToggleOnIcon } from "@/app/components/icons/assessment"; + +interface CompactToggleSwitchProps { + checked: boolean; + onChange: () => void; + title: string; +} + +export default function CompactToggleSwitch({ + checked, + onChange, + title, +}: CompactToggleSwitchProps) { + return ( + + ); +} diff --git a/app/components/assessment/ConfigPanel.tsx b/app/components/assessment/ConfigPanel.tsx new file mode 100644 index 00000000..1123827c --- /dev/null +++ b/app/components/assessment/ConfigPanel.tsx @@ -0,0 +1,165 @@ +"use client"; + +// Multi-step wizard (Mapper → Eliminatory → Evaluation → Post Processing → Review) +import { Button } from "@/app/components/ui"; +import { DatabaseIcon } from "@/app/components/icons"; +import { ASSESSMENT_CONFIG_STEPS } from "@/app/lib/assessment/constants"; +import type { ConfigPanelProps } from "@/app/lib/types/assessment"; +import ColumnMapperStep from "./ColumnMapperStep"; +import PrefilterStep from "./PrefilterStep"; +import PostProcessingStep from "./PostProcessingStep"; +import PromptAndConfigStep from "./PromptAndConfigStep"; +import ReviewStep from "./ReviewStep"; +import Stepper from "./Stepper"; + +export default function ConfigPanel({ + canSubmitAssessment, + columns, + columnMapping, + completedSteps, + configStep, + configs, + datasetId, + experimentName, + formState, + hasDataset, + isSubmitting, + prefilterConfig, + outputSchema, + systemInstruction, + promptTemplate, + postProcessingConfig, + setPostProcessingConfig, + sampleRow, + setActiveTabToDatasets, + setColumnMapping, + setConfigStep, + setConfigs, + setExperimentName, + setPrefilterConfig, + setOutputSchema, + setSystemInstruction, + setPromptTemplate, + submitBlockerMessage, + onSubmit, + onStepComplete, +}: ConfigPanelProps) { + if (!hasDataset) { + return ( +
+
+ +

+ No dataset selected +

+

+ Select a dataset first from the Datasets tab +

+ +
+
+ ); + } + + return ( + <> + +
+
+ onStepComplete(1)} + onBack={setActiveTabToDatasets} + /> +
+ +
+ a.column)} + prefilterConfig={prefilterConfig} + setPrefilterConfig={setPrefilterConfig} + onNext={() => onStepComplete(2)} + onBack={() => setConfigStep(1)} + /> +
+ +
+ onStepComplete(3)} + onBack={() => setConfigStep(2)} + /> +
+ +
+ onStepComplete(4)} + onBack={() => setConfigStep(3)} + /> +
+ +
+ setConfigStep(4)} + onEditStep={setConfigStep} + /> +
+
+ + ); +} diff --git a/app/components/assessment/DataViewModal.tsx b/app/components/assessment/DataViewModal.tsx new file mode 100644 index 00000000..4f13df40 --- /dev/null +++ b/app/components/assessment/DataViewModal.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useMemo } from "react"; +import { Modal } from "@/app/components/ui"; +import CloseIcon from "@/app/components/icons/document/CloseIcon"; +import { isBlankCell } from "@/app/lib/utils/assessment"; + +interface DataViewModalProps { + title: string; + subtitle?: string; + headers: string[]; + rows: string[][]; + onClose: () => void; +} + +export default function DataViewModal({ + title, + subtitle, + headers: rawHeaders, + rows: rawRows, + onClose, +}: DataViewModalProps) { + const { headers, rows } = useMemo(() => { + const keptColIdx = rawHeaders + .map((_, colIdx) => colIdx) + .filter((colIdx) => rawRows.some((row) => !isBlankCell(row[colIdx]))); + + const filteredHeaders = keptColIdx.map((idx) => rawHeaders[idx]); + const filteredRows = rawRows + .map((row) => keptColIdx.map((idx) => row[idx] ?? "")) + .filter((row) => row.some((cell) => !isBlankCell(cell))); + + return { headers: filteredHeaders, rows: filteredRows }; + }, [rawHeaders, rawRows]); + + return ( + +
+
+

{title}

+

+ {subtitle ?? `${rows.length} rows · ${headers.length} columns`} +

+
+ +
+
+ + + + + {headers.map((header, i) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + + {row.map((cell, cellIdx) => ( + + ))} + + ))} + +
+ {header} +
+ {rowIdx + 1} + +
+ {cell || } +
+
+
+
+ ); +} diff --git a/app/components/assessment/DatasetsTab.tsx b/app/components/assessment/DatasetsTab.tsx new file mode 100644 index 00000000..ddc3d53d --- /dev/null +++ b/app/components/assessment/DatasetsTab.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button, Modal } from "@/app/components/ui"; +import { useAssessmentDatasetsTab } from "@/app/hooks/useAssessmentDatasetsTab"; +import type { DatasetsTabProps } from "@/app/lib/types/assessment"; +import { WarningIcon } from "@/app/components/icons"; +import DataViewModal from "@/app/components/assessment/DataViewModal"; +import CreatePanel from "@/app/components/assessment/datasets/CreatePanel"; +import DatasetList from "@/app/components/assessment/datasets/DatasetList"; + +export default function DatasetsTab(props: DatasetsTabProps) { + const { datasetId, onNext } = props; + const { + datasets, + isLoading, + isLoadingColumns, + viewingId, + canProceed, + datasetName, + datasetDescription, + uploadedFile, + isDragging, + isUploading, + fileInputRef, + viewModalData, + confirmDeleteId, + deletingId, + datasetPendingDelete, + setDatasetName, + setDatasetDescription, + setUploadedFile, + setIsDragging, + setConfirmDeleteId, + setViewModalData, + handleFileSelect, + resetForm, + handleCreateDataset, + handleDatasetSelect, + handleViewDataset, + handleDeleteDataset, + handleDrop, + } = useAssessmentDatasetsTab(props); + + return ( +
+ + + { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onRemoveFile={() => { + setUploadedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }} + onResetForm={resetForm} + onCreateDataset={handleCreateDataset} + /> + + {viewModalData && ( + setViewModalData(null)} + /> + )} + + {confirmDeleteId !== null && ( + setConfirmDeleteId(null)} + maxWidth="max-w-md" + maxHeight="max-h-[90vh]" + showClose={false} + > +
+
+
+ + + +
+
+

+ Delete dataset +

+

+ Are you sure you want to delete +

+

+ {datasetPendingDelete?.dataset_name}? +

+

+ This action cannot be undone. +

+
+
+
+
+ + +
+
+ )} +
+ ); +} diff --git a/app/components/assessment/DownloadDropdown.tsx b/app/components/assessment/DownloadDropdown.tsx new file mode 100644 index 00000000..92037a0d --- /dev/null +++ b/app/components/assessment/DownloadDropdown.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "@/app/components/ui"; +import { ChevronDownIcon } from "@/app/components/icons"; +import { DownloadIcon } from "@/app/components/icons/assessment"; +import LoadingSpinner from "@/app/components/assessment/LoadingSpinner"; +import { DOWNLOAD_MENU_WIDTH } from "@/app/lib/assessment/constants"; +import type { ExportFormat } from "@/app/lib/types/assessment"; + +interface DownloadDropdownProps { + onDownload: (format: ExportFormat) => void; + disabled?: boolean; + loading?: boolean; +} + +export default function DownloadDropdown({ + onDownload, + disabled, + loading, +}: DownloadDropdownProps) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [rect, setRect] = useState<{ top: number; left: number } | null>(null); + + // Position the portal menu under the trigger, right-aligned. + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const r = triggerRef.current.getBoundingClientRect(); + setRect({ top: r.bottom + 4, left: r.right - DOWNLOAD_MENU_WIDTH }); + }, [open]); + + useEffect(() => { + if (!open) return; + const handlePointer = (e: MouseEvent) => { + const t = e.target as Node; + if (!triggerRef.current?.contains(t) && !menuRef.current?.contains(t)) { + setOpen(false); + } + }; + // Fixed menu can't follow scroll — close instead of drift. + const handleScrollOrResize = () => setOpen(false); + document.addEventListener("mousedown", handlePointer); + window.addEventListener("scroll", handleScrollOrResize, true); + window.addEventListener("resize", handleScrollOrResize); + return () => { + document.removeEventListener("mousedown", handlePointer); + window.removeEventListener("scroll", handleScrollOrResize, true); + window.removeEventListener("resize", handleScrollOrResize); + }; + }, [open]); + + return ( +
+ + {open && + rect && + typeof document !== "undefined" && + createPortal( +
+ {( + [ + ["csv", "CSV File"], + ["xlsx", "Excel Sheet"], + ] as const + ).map(([fmt, label]) => ( + + ))} +
, + document.body, + )} +
+ ); +} diff --git a/app/components/assessment/EvaluationsTab.tsx b/app/components/assessment/EvaluationsTab.tsx new file mode 100644 index 00000000..7d8ec567 --- /dev/null +++ b/app/components/assessment/EvaluationsTab.tsx @@ -0,0 +1,266 @@ +"use client"; + +// Assessment Evaluations tab — shows run cards with status, retry, and CSV export. +import { RunsListSkeleton } from "@/app/components"; +import { Button, Select } from "@/app/components/ui"; +import { useToast } from "@/app/hooks/useToast"; +import { + DatabaseIcon, + ClipboardIcon, + RefreshIcon, +} from "@/app/components/icons"; +import DownloadDropdown from "./DownloadDropdown"; +import AssessmentChildRunCard from "./AssessmentChildRunCard"; +import { + canRetryStatus, + formatStatusLabel, + getResultTone, +} from "@/app/lib/assessment/results"; +import { + ASSESSMENT_CARD_CLASSES, + STATUS_BADGE_CLASSES, + STATUS_FILTER_OPTIONS, +} from "@/app/lib/assessment/constants"; +import { formatRelativeTime } from "@/app/lib/utils"; +import type { EvaluationsTabProps } from "@/app/lib/types/assessment"; +import useAssessmentResults from "@/app/hooks/useAssessmentResults"; + +export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { + const toast = useToast(); + const { + assessments, + filteredRuns, + childRunsByAssessment, + configDetailsByKey, + configLoadingKeys, + configErrorKeys, + isLoading, + statusFilter, + setStatusFilter, + rerunningId, + resumingId, + retryingAssessmentId, + expandedId, + downloadingId, + loadAssessments, + handleExpand, + handleRetryAssessment, + handleRerun, + handleResume, + handlePreview, + handleAssessmentDownload, + handleRunDownload, + handleSavePostProcessing, + handleFetchRunColumns, + } = useAssessmentResults({ onForbidden, toast }); + + return ( +
+
+
+

+ Evaluation Runs +

+
+