Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 33 additions & 72 deletions app/(main)/assessment/results/[runId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
"use client";

import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useParams, useRouter, 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";
import ResultsToolbar from "@/app/components/assessment/results/ResultsToolbar";
import { useRunResults } from "@/app/hooks";
import type { AssessmentMethodValue } from "@/app/lib/types/assessment";
import {
downloadCsv,
jsonResultsToTableData,
} from "@/app/lib/assessment/results";

const SpreadsheetView = dynamic(
() => import("@/app/components/assessment/SpreadsheetView"),
{
ssr: false,
loading: () => (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<div className="flex h-full w-full items-center justify-center bg-bg-primary">
<Loader size="lg" message="Loading spreadsheet..." />
</div>
),
Expand All @@ -25,85 +26,45 @@ const SpreadsheetView = dynamic(
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 router = useRouter();

const [headers, setHeaders] = useState<string[] | null>(null);
const [rows, setRows] = useState<string[][] | null>(null);
const [error, setError] = useState<string | null>(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<string, unknown>[] } | Record<string, unknown>[]
>(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey);
const results: Record<string, unknown>[] = 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]);
const assessmentId = params.runId;
const method =
(searchParams.get("method") as AssessmentMethodValue | null) ?? "BATCH";
const title = searchParams.get("title") ?? "Run results";
const { results, headers, rows, isLoading, error } = useRunResults(
assessmentId ? { assessment_id: assessmentId, method } : null,
);

if (error) {
return (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<div className="flex h-screen w-full items-center justify-center bg-bg-primary">
<p className="text-sm text-text-secondary">{error}</p>
</div>
);
}

if (!headers || !rows) {
if (isLoading) {
return (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<div className="flex h-screen w-full items-center justify-center bg-bg-primary">
<Loader size="lg" message="Loading results..." />
</div>
);
}

return (
<SpreadsheetView
runId={runId}
title={title}
subtitle={`${rows.length} rows · ${headers.length} columns`}
headers={headers}
rows={rows}
/>
<div className="flex h-screen w-full flex-col bg-bg-primary">
<ResultsToolbar
title={title}
subtitle={`${rows.length} rows · ${headers.length} columns`}
onBack={() => router.push("/assessment")}
onDownload={() => {
const full = jsonResultsToTableData(results);
downloadCsv(title, [full.headers, ...full.rows]);
}}
/>

<SpreadsheetView runId={assessmentId} headers={headers} rows={rows} />
</div>
);
}
84 changes: 32 additions & 52 deletions app/(main)/settings/credentials/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import {
Credential,
ProviderDef,
} from "@/app/lib/types/credentials";
import { getExistingForProvider } from "@/app/lib/utils";
import {
buildCredentialPayload,
getExistingForProvider,
missingCredentialFields,
populateCredentialForm,
} from "@/app/lib/utils";
import ProviderSidebar from "@/app/components/settings/ProviderSidebar";
import CredentialForm from "@/app/components/settings/credentials/CredentialForm";
import { apiFetch } from "@/app/lib/apiClient";
Expand Down Expand Up @@ -46,23 +51,9 @@ export default function CredentialsPage() {
// Re-populate form when provider or credentials change
useEffect(() => {
const existing = getExistingForProvider(selectedProvider, credentials);
if (existing) {
setExistingCredential(existing);
setIsActive(existing.is_active);
const populated: Record<string, string> = {};
selectedProvider.fields.forEach((f) => {
populated[f.key] = existing.credential[f.key] || "";
});
setFormValues(populated);
} else {
setExistingCredential(null);
setIsActive(true);
const blank: Record<string, string> = {};
selectedProvider.fields.forEach((f) => {
blank[f.key] = "";
});
setFormValues(blank);
}
setExistingCredential(existing);
setIsActive(existing ? existing.is_active : true);
setFormValues(populateCredentialForm(selectedProvider, existing));
}, [selectedProvider, credentials]);

const loadCredentials = async () => {
Expand All @@ -80,45 +71,47 @@ export default function CredentialsPage() {
}
};

const buildCredentialBody = (isUpdate: boolean) => {
const innerPayload: Record<string, string> = {};
selectedProvider.fields.forEach((f) => {
innerPayload[f.key] = formValues[f.key].trim();
});
return {
provider: selectedProvider.credentialKey,
is_active: isActive,
credential: isUpdate
? innerPayload
: { [selectedProvider.credentialKey]: innerPayload },
};
};

const handleSave = async () => {
if (!isAuthenticated) {
toast.error("Please add an API key in Keystore first");
return;
}
const missing = selectedProvider.fields.filter(
(f) => !formValues[f.key]?.trim(),
);
const missing = missingCredentialFields(selectedProvider, formValues);
if (missing.length > 0) {
toast.error(`Please fill in: ${missing.map((f) => f.label).join(", ")}`);
return;
}

const built = buildCredentialPayload(selectedProvider, formValues);
if (built.error) {
toast.error(built.error);
return;
}
if (Object.keys(built.payload).length === 0) {
toast.error("No changes to save");
return;
}

setIsSaving(true);
try {
if (existingCredential) {
await apiFetch("/api/credentials", apiKeys[0]?.key ?? "", {
method: "PATCH",
body: JSON.stringify(buildCredentialBody(true)),
body: JSON.stringify({
provider: selectedProvider.credentialKey,
is_active: isActive,
credential: built.payload,
}),
});
toast.success(`${selectedProvider.name} credentials updated`);
} else {
await apiFetch("/api/credentials", apiKeys[0]?.key ?? "", {
method: "POST",
body: JSON.stringify(buildCredentialBody(false)),
body: JSON.stringify({
provider: selectedProvider.credentialKey,
is_active: isActive,
credential: { [selectedProvider.credentialKey]: built.payload },
}),
});
toast.success(`${selectedProvider.name} credentials saved`);
}
Expand All @@ -134,21 +127,8 @@ export default function CredentialsPage() {

const handleCancel = () => {
const existing = getExistingForProvider(selectedProvider, credentials);
if (existing) {
setIsActive(existing.is_active);
const populated: Record<string, string> = {};
selectedProvider.fields.forEach((f) => {
populated[f.key] = existing.credential[f.key] || "";
});
setFormValues(populated);
} else {
const blank: Record<string, string> = {};
selectedProvider.fields.forEach((f) => {
blank[f.key] = "";
});
setFormValues(blank);
setIsActive(true);
}
setIsActive(existing ? existing.is_active : true);
setFormValues(populateCredentialForm(selectedProvider, existing));
};

const handleDelete = async () => {
Expand Down
19 changes: 19 additions & 0 deletions app/api/assessment/batch/[assessment_id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy";
import type { RouteContext } from "@/app/lib/types/assessment";

export async function GET(
request: NextRequest,
context: RouteContext<"assessment_id">,
) {
try {
const { assessment_id } = await context.params;
return await proxyJsonResponse(
request,
`/api/v1/assessments/${assessment_id}`,
{ method: "GET" },
);
} catch (error: unknown) {
return proxyErrorResponse("Assessment batch detail proxy error:", error);
}
}
33 changes: 33 additions & 0 deletions app/api/assessment/batch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import {
proxyErrorResponse,
proxyJsonResponse,
withQueryParams,
} from "@/app/api/_routeProxy";

const BATCH_ENDPOINT = "/api/v1/assessments";

export async function GET(request: NextRequest) {
try {
const queryParams = new URLSearchParams(request.nextUrl.searchParams);
return await proxyJsonResponse(
request,
withQueryParams(BATCH_ENDPOINT, queryParams),
{ method: "GET" },
);
} catch (error: unknown) {
return proxyErrorResponse("Assessment batch list proxy error:", error);
}
}

export async function POST(request: NextRequest) {
try {
const body = await request.json();
return await proxyJsonResponse(request, BATCH_ENDPOINT, {
method: "POST",
body: JSON.stringify(body),
});
} catch (error: unknown) {
return proxyErrorResponse("Assessment batch create proxy error:", error);
}
}
Loading
Loading