js - ts + fix
This commit is contained in:
@@ -6,9 +6,24 @@
|
||||
// @UX_STATE Error -> error field exposed, component shows retry banner
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ url }) {
|
||||
interface TasksResponse {
|
||||
items?: unknown[];
|
||||
tasks?: unknown[];
|
||||
results?: unknown[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface LoadResult {
|
||||
tasks: unknown[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ url }): Promise<LoadResult> => {
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const page_size = parseInt(url.searchParams.get('page_size') || '20', 10);
|
||||
const is_active = url.searchParams.get('is_active') ?? undefined;
|
||||
@@ -16,30 +31,30 @@ export async function load({ url }) {
|
||||
const search = url.searchParams.get('search') || undefined;
|
||||
|
||||
try {
|
||||
/** @type {{ tasks: Array<any>, total: number }} */
|
||||
const result = await api.getValidationTasks({
|
||||
const result: TasksResponse = await api.getValidationTasks({
|
||||
page,
|
||||
page_size,
|
||||
...(is_active !== undefined ? { is_active: is_active === 'true' } : {}),
|
||||
...(environment_id ? { environment_id } : {}),
|
||||
...(search ? { search } : {})
|
||||
...(search ? { search } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
tasks: result?.items ?? result?.tasks ?? result?.results ?? [],
|
||||
total: result?.total ?? 0,
|
||||
page,
|
||||
page_size
|
||||
page_size,
|
||||
};
|
||||
} catch (e) {
|
||||
log('validation-tasks', 'EXPLORE', 'Load failed', {}, e instanceof Error ? e.message : 'unknown');
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'unknown';
|
||||
log('validation-tasks', 'EXPLORE', 'Load failed', {}, message);
|
||||
return {
|
||||
tasks: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size,
|
||||
error: /** @type {Error} */ (e).message || 'Failed to load validation tasks'
|
||||
error: message || 'Failed to load validation tasks',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
// #endregion ValidationTasksPageLoad
|
||||
@@ -1,32 +0,0 @@
|
||||
// #region ValidationTaskDetailPageLoad [C:2] [TYPE Module] [SEMANTICS validation, task, detail, load]
|
||||
// @BRIEF SvelteKit load function for the validation task detail page — fetches task metadata and paginated run history.
|
||||
// @RELATION CALLS -> [ValidationApi]
|
||||
// @PRE policyId route param is a non-empty string.
|
||||
// @POST Returns { task, runs, total } or redirects to 404 on missing task.
|
||||
import { getValidationTask, getValidationRuns } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ params }) {
|
||||
const { policyId } = params;
|
||||
|
||||
const [taskRes, runsRes] = await Promise.all([
|
||||
getValidationTask(policyId).catch(() => null),
|
||||
getValidationRuns(policyId, { page: 1, page_size: 20 }).catch(() => ({ results: [], total: 0 })),
|
||||
]);
|
||||
|
||||
if (!taskRes) {
|
||||
error(404, 'Task not found');
|
||||
}
|
||||
|
||||
const task = taskRes.task || taskRes;
|
||||
const runs = Array.isArray(runsRes) ? runsRes : (runsRes?.results || runsRes?.items || []);
|
||||
const total = runsRes?.total || runsRes?.count || runs.length;
|
||||
|
||||
return {
|
||||
task,
|
||||
runs,
|
||||
total,
|
||||
};
|
||||
}
|
||||
// #endregion ValidationTaskDetailPageLoad
|
||||
46
frontend/src/routes/validation-tasks/[policyId]/+page.ts
Normal file
46
frontend/src/routes/validation-tasks/[policyId]/+page.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// #region ValidationTaskDetailPageLoad [C:2] [TYPE Module] [SEMANTICS validation, task, detail, load]
|
||||
// @BRIEF SvelteKit load function for the validation task detail page — fetches task metadata and paginated run history.
|
||||
// @RELATION CALLS -> [ValidationApi]
|
||||
// @PRE policyId route param is a non-empty string.
|
||||
// @POST Returns { task, runs, total } or redirects to 404 on missing task.
|
||||
import { getValidationTask, getValidationRuns } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
interface TaskResponse {
|
||||
task?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RunsResponse {
|
||||
results?: unknown[];
|
||||
items?: unknown[];
|
||||
total?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface LoadResult {
|
||||
task: Record<string, unknown>;
|
||||
runs: unknown[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params }): Promise<LoadResult> => {
|
||||
const { policyId } = params as { policyId: string };
|
||||
|
||||
const [taskRes, runsRes] = await Promise.all([
|
||||
getValidationTask(policyId).catch(() => null) as Promise<TaskResponse | null>,
|
||||
getValidationRuns(policyId, { page: 1, page_size: 20 }).catch(() => ({ results: [], total: 0 })) as Promise<RunsResponse>,
|
||||
]);
|
||||
|
||||
if (!taskRes) {
|
||||
error(404, 'Task not found');
|
||||
}
|
||||
|
||||
const task = (taskRes as TaskResponse).task || taskRes;
|
||||
const runs = Array.isArray(runsRes) ? runsRes : ((runsRes as RunsResponse)?.results || (runsRes as RunsResponse)?.items || []);
|
||||
const total = (runsRes as RunsResponse)?.total || (runsRes as RunsResponse)?.count || runs.length;
|
||||
|
||||
return { task, runs, total };
|
||||
};
|
||||
// #endregion ValidationTaskDetailPageLoad
|
||||
@@ -1,32 +0,0 @@
|
||||
// #region ValidationTaskEditPageLoad [C:2] [TYPE Function] [SEMANTICS validation-tasks,edit,load,task,providers]
|
||||
// @BRIEF Load function for the edit validation task page — fetches existing task data and LLM providers list.
|
||||
// @RELATION CALLS -> [ApiModule.getValidationTask]
|
||||
// @RELATION CALLS -> [ApiModule.getLlmProviders]
|
||||
// @POST Returns { task, llmProviders } or redirects to 404 on missing task.
|
||||
import { api } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ params }) {
|
||||
const { policyId } = params;
|
||||
|
||||
const [taskResult, providersResult] = await Promise.all([
|
||||
api.getValidationTask(policyId).catch(() => null),
|
||||
api.getLlmProviders().catch(() => []),
|
||||
]);
|
||||
|
||||
if (!taskResult) {
|
||||
error(404, 'Task not found');
|
||||
}
|
||||
|
||||
const task = taskResult.task || taskResult;
|
||||
const providers = Array.isArray(providersResult)
|
||||
? providersResult
|
||||
: (providersResult?.providers ?? providersResult?.results ?? []);
|
||||
|
||||
return {
|
||||
task,
|
||||
llmProviders: providers,
|
||||
};
|
||||
}
|
||||
// #endregion ValidationTaskEditPageLoad
|
||||
@@ -0,0 +1,45 @@
|
||||
// #region ValidationTaskEditPageLoad [C:2] [TYPE Function] [SEMANTICS validation-tasks,edit,load,task,providers]
|
||||
// @BRIEF Load function for the edit validation task page — fetches existing task data and LLM providers list.
|
||||
// @RELATION CALLS -> [ApiModule.getValidationTask]
|
||||
// @RELATION CALLS -> [ApiModule.getLlmProviders]
|
||||
// @POST Returns { task, llmProviders } or redirects to 404 on missing task.
|
||||
import { api } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
interface TaskResponse {
|
||||
task?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProvidersResponse {
|
||||
providers?: unknown[];
|
||||
results?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface LoadResult {
|
||||
task: Record<string, unknown>;
|
||||
llmProviders: unknown[];
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params }): Promise<LoadResult> => {
|
||||
const { policyId } = params as { policyId: string };
|
||||
|
||||
const [taskResult, providersResult] = await Promise.all([
|
||||
api.getValidationTask(policyId).catch(() => null) as Promise<TaskResponse | null>,
|
||||
api.getLlmProviders().catch(() => []) as Promise<unknown[] | ProvidersResponse>,
|
||||
]);
|
||||
|
||||
if (!taskResult) {
|
||||
error(404, 'Task not found');
|
||||
}
|
||||
|
||||
const task = (taskResult as TaskResponse).task || taskResult;
|
||||
const providers = Array.isArray(providersResult)
|
||||
? providersResult
|
||||
: ((providersResult as ProvidersResponse)?.providers ?? (providersResult as ProvidersResponse)?.results ?? []);
|
||||
|
||||
return { task, llmProviders: providers };
|
||||
};
|
||||
// #endregion ValidationTaskEditPageLoad
|
||||
@@ -1,32 +0,0 @@
|
||||
// #region ValidationRunDetailPageLoad [C:2] [TYPE Module] [SEMANTICS validation, run, detail, report, load]
|
||||
// @BRIEF SvelteKit load function for the validation run detail report page — fetches full run results including dashboards, issues, screenshots, dataset health, and logs.
|
||||
// @RELATION CALLS -> [ValidationApi]
|
||||
// @PRE policyId and runId route params are non-empty strings.
|
||||
// @POST Returns { run } or redirects to 404 on missing run.
|
||||
import { getValidationRunDetail } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ params }) {
|
||||
const { policyId, runId } = params;
|
||||
|
||||
const runDetail = await getValidationRunDetail(policyId, runId).catch(() => null);
|
||||
|
||||
if (!runDetail) {
|
||||
error(404, 'Run not found');
|
||||
}
|
||||
|
||||
// Normalise shape: merge records into run as dashboards for the report page
|
||||
const run = runDetail.run || runDetail;
|
||||
const records = runDetail.records || [];
|
||||
|
||||
// Normalise field names from backend API shape to frontend template keys
|
||||
run.dashboards = records.map((rec) => ({
|
||||
...rec,
|
||||
screenshots: rec.screenshots || rec.screenshot_paths || rec.tab_screenshots || [],
|
||||
logs_sent: rec.logs_sent || rec.logs_sent_to_llm || [],
|
||||
}));
|
||||
|
||||
return { run };
|
||||
}
|
||||
// #endregion ValidationRunDetailPageLoad
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- @UX_STATE Populated -> Run header + collapsible dashboard list with expanded detail sections -->
|
||||
<!-- @UX_FEEDBACK Screenshot opens in new tab on click -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(data), LocalState -> $state(...) -->
|
||||
<!-- @UX_TEST: Populated -> {expand dashboard with screenshots, expected: first screenshot auto-loads, tab badge shows if issues match} -->
|
||||
<!-- @UX_TEST: Populated -> {click "Task execution logs", expected: task logs table appears with time/level/source/message columns} -->
|
||||
<!-- @UX_TEST: Populated -> {expand dashboard with issues, expected: tab buttons show red/yellow badge with issue count} -->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
@@ -123,6 +126,47 @@
|
||||
return 'bg-gray-100 text-gray-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a tab has issues by matching its label/idx against issue locations.
|
||||
* Uses tab_index from LLM output (preferred) or heuristic label matching.
|
||||
* @param {string} tabLabel - Human-readable tab name (e.g. "Overview")
|
||||
* @param {number} tabIdx - 0-based tab index
|
||||
* @param {number} totalTabs - total number of tabs for this dashboard
|
||||
* @param {Array} issues - Dashboard issues with {severity, location, tab_index}
|
||||
* @returns {{count: number, severity: string}|null}
|
||||
*/
|
||||
function getTabIssueSeverity(tabLabel, tabIdx, totalTabs, issues) {
|
||||
if (!issues?.length) return null;
|
||||
// Prefer tab_index from LLM output (exact match)
|
||||
const byIndex = issues.filter(i => i.tab_index === tabIdx || i.tab_index === String(tabIdx));
|
||||
if (byIndex.length > 0) {
|
||||
const worst = byIndex.some(i => i.severity === 'FAIL') ? 'FAIL'
|
||||
: byIndex.some(i => i.severity === 'WARN') ? 'WARN' : 'INFO';
|
||||
return { count: byIndex.length, severity: worst };
|
||||
}
|
||||
// Fallback: heuristic label matching
|
||||
const lowerLabel = tabLabel?.toLowerCase();
|
||||
let matching = [];
|
||||
if (lowerLabel) {
|
||||
matching = issues.filter(issue => {
|
||||
if (!issue.location) return false;
|
||||
const lowerLoc = issue.location.toLowerCase();
|
||||
return lowerLabel.includes(lowerLoc) || lowerLoc.includes(lowerLabel);
|
||||
});
|
||||
}
|
||||
// Fallback: show unmatched issues on the LAST tab only
|
||||
const unmatched = issues.filter(i => i.tab_index === -1 || i.tab_index === '-1' || i.tab_index === undefined || i.tab_index === null);
|
||||
if (matching.length === 0 && tabIdx === totalTabs - 1 && unmatched.length > 0) {
|
||||
const worst = unmatched.some(i => i.severity === 'FAIL') ? 'FAIL'
|
||||
: unmatched.some(i => i.severity === 'WARN') ? 'WARN' : 'INFO';
|
||||
return { count: unmatched.length, severity: worst };
|
||||
}
|
||||
if (matching.length === 0) return null;
|
||||
const worst = matching.some(i => i.severity === 'FAIL') ? 'FAIL'
|
||||
: matching.some(i => i.severity === 'WARN') ? 'WARN' : 'INFO';
|
||||
return { count: matching.length, severity: worst };
|
||||
}
|
||||
|
||||
/** @param {string} dashboardId */
|
||||
function toggleDashboard(dashboardId) {
|
||||
const next = new Set(expandedDashboards);
|
||||
@@ -130,6 +174,17 @@
|
||||
next.delete(dashboardId);
|
||||
} else {
|
||||
next.add(dashboardId);
|
||||
// Auto-select first screenshot tab and trigger preload
|
||||
if (!selectedScreenshotTab[dashboardId]) {
|
||||
selectedScreenshotTab = { ...selectedScreenshotTab, [dashboardId]: '0' };
|
||||
const db = dashboards.find((/** @type {any} */ d) => d.id === dashboardId || d.dashboard_id === dashboardId);
|
||||
if (db?.screenshots?.length) {
|
||||
const tabKey = `${dashboardId}_0`;
|
||||
if (!screenshotUrls[tabKey]) {
|
||||
loadScreenshot(db.screenshots[0].path || db.screenshots[0], tabKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expandedDashboards = next;
|
||||
}
|
||||
@@ -147,13 +202,27 @@
|
||||
logsSentExpanded = next;
|
||||
}
|
||||
|
||||
/** @param {string} key */
|
||||
function toggleTaskLogs(key) {
|
||||
const next = new Set(taskLogsExpanded);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
taskLogsExpanded = next;
|
||||
/** @param {string} key */
|
||||
function toggleTaskLogs(key) {
|
||||
const next = new Set(taskLogsExpanded);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
// Load task logs on first expand (client-side)
|
||||
const dId = key.replace('tasklogs_', '');
|
||||
const db = dashboards.find((/** @type {any} */ d) => d.id === dId || d.dashboard_id === dId);
|
||||
if (db && !db._taskLogsLoaded && run?.task_id) {
|
||||
db._taskLogsLoaded = true;
|
||||
api.getTaskLogs(run.task_id, { limit: 200 }).then((logs) => {
|
||||
db.task_logs = logs || [];
|
||||
}).catch(() => {
|
||||
db.task_logs = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
taskLogsExpanded = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a screenshot blob from the storage API and cache it.
|
||||
@@ -181,7 +250,7 @@
|
||||
goto(`/validation-tasks/${policyId}`);
|
||||
}
|
||||
|
||||
// When a dashboard is expanded and has path A screenshots, preload the first one
|
||||
// When a dashboard is expanded and has screenshots, preload the first one
|
||||
$effect(() => {
|
||||
const expanded = Array.from(expandedDashboards);
|
||||
for (const dId of expanded) {
|
||||
@@ -189,7 +258,7 @@
|
||||
const db = dashboards.find((/** @type {any} */ d) => d.id === dId || d.dashboard_id === dId);
|
||||
if (!db) continue;
|
||||
const screenshots = db.screenshots;
|
||||
if (db.path === 'A' && screenshots?.length) {
|
||||
if (screenshots?.length) {
|
||||
const tabKey = `${dId}_0`;
|
||||
if (!screenshotUrls[tabKey]) {
|
||||
loadScreenshot(screenshots[0].path || screenshots[0], tabKey);
|
||||
@@ -570,10 +639,12 @@
|
||||
<p class="text-sm text-gray-400">{$t.validation?.no_screenshots || 'No screenshots saved'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Tab Selector -->
|
||||
<!-- Tab Selector with issue badges -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{#each screenshots as shot, idx (idx)}
|
||||
{@const tabKey = `${dId}_${idx}`}
|
||||
{@const tabLabel = shot.label || ''}
|
||||
{@const issueTag = getTabIssueSeverity(tabLabel, dbIssues)}
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedScreenshotTab = { ...selectedScreenshotTab, [dId]: String(idx) };
|
||||
@@ -581,12 +652,18 @@
|
||||
loadScreenshot(shot.path || shot, tabKey);
|
||||
}
|
||||
}}
|
||||
class="px-3 py-1.5 text-xs rounded-lg border transition-colors
|
||||
class="px-3 py-1.5 text-xs rounded-lg border transition-colors inline-flex items-center gap-1.5
|
||||
{(selectedScreenshotTab[dId] || '0') === String(idx)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}
|
||||
{issueTag ? (issueTag.severity === 'FAIL' ? 'ring-2 ring-red-300' : 'ring-2 ring-yellow-300') : ''}"
|
||||
>
|
||||
{$t.validation?.tab || 'Tab'} {idx + 1}{shot.label ? `: ${shot.label}` : ''}
|
||||
{#if issueTag}
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-bold {issueTag.severity === 'FAIL' ? 'bg-red-500 text-white' : 'bg-yellow-400 text-yellow-900'}">
|
||||
{issueTag.count}
|
||||
</span>
|
||||
{/if}
|
||||
{$t.validation?.tab || 'Tab'} {idx + 1}{tabLabel ? `: ${tabLabel}` : ''}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// #region ValidationRunDetailPageLoad [C:2] [TYPE Module] [SEMANTICS validation, run, detail, report, load]
|
||||
// @BRIEF SvelteKit load function for the validation run detail report page — fetches full run results including dashboards, issues, screenshots, dataset health, and logs.
|
||||
// @RELATION CALLS -> [ValidationApi]
|
||||
// @PRE policyId and runId route params are non-empty strings.
|
||||
// @POST Returns { run } or redirects to 404 on missing run.
|
||||
import { getValidationRunDetail } from '$lib/api.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// ── Type definitions for API boundary ─────────────────────────
|
||||
interface RecordField {
|
||||
path?: string;
|
||||
label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RunRecord {
|
||||
id?: string;
|
||||
path?: string;
|
||||
execution_path?: string | null;
|
||||
screenshot_paths?: string[] | null;
|
||||
logs_sent_to_llm?: string[] | null;
|
||||
raw_response?: string | Record<string, unknown> | null;
|
||||
task_logs?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RunDetailResponse {
|
||||
run?: Record<string, unknown>;
|
||||
records?: RunRecord[];
|
||||
}
|
||||
|
||||
interface ScreenshotEntry {
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Extract a field from a record, falling back to raw_response JSON if top-level is empty. */
|
||||
function extractFromRaw(rec: Record<string, unknown>, field: string): unknown[] {
|
||||
const val = rec[field];
|
||||
if (val && (Array.isArray(val) ? (val as unknown[]).length > 0 : true)) {
|
||||
return val as unknown[];
|
||||
}
|
||||
try {
|
||||
const raw = rec.raw_response;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
|
||||
return (parsed as Record<string, unknown>)[field] as unknown[] || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a record used Path A (screenshots) by looking at execution_path or screenshot_paths. */
|
||||
function isPathA(rec: Record<string, unknown>): boolean {
|
||||
if (rec.path === 'A') return true;
|
||||
if (rec.execution_path === 'screenshot' || rec.execution_path === 'multimodal') return true;
|
||||
const shots = extractFromRaw(rec, 'screenshot_paths');
|
||||
return Array.isArray(shots) && shots.length > 0;
|
||||
}
|
||||
|
||||
/** Extract a human-readable tab label from a screenshot path.
|
||||
* Filename format: {dashboard_id}_{label}_{timestamp}_d{depth}.webp
|
||||
* e.g. "11_Overview_1780300006_d0.webp" → "Overview"
|
||||
*/
|
||||
function tabLabelFromPath(shot: string | { path?: string }): string {
|
||||
const filePath = typeof shot === 'string' ? shot : (shot?.path || '');
|
||||
const basename = filePath.split('/').pop()?.replace(/\.\w+$/, '') || '';
|
||||
const match = basename.match(/^\d+_(.+)_\d+_d\d+$/);
|
||||
if (match) return match[1].replace(/_/g, ' ');
|
||||
const fallback = basename.match(/^\d+_(.+)_\d+$/);
|
||||
if (fallback) return fallback[1].replace(/_/g, ' ');
|
||||
return '';
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const { policyId, runId } = params as { policyId: string; runId: string };
|
||||
|
||||
const runDetail: RunDetailResponse | null = await getValidationRunDetail(policyId, runId).catch(() => null);
|
||||
|
||||
if (!runDetail) {
|
||||
error(404, 'Run not found');
|
||||
}
|
||||
|
||||
// Normalise shape: merge records into run as dashboards for the report page
|
||||
const run = (runDetail.run || runDetail) as Record<string, unknown>;
|
||||
const records: RunRecord[] = runDetail.records || [];
|
||||
|
||||
// Normalise field names from backend API shape to frontend template keys
|
||||
run.dashboards = records.map((rec: RunRecord) => {
|
||||
const rawShots = extractFromRaw(rec as Record<string, unknown>, 'screenshot_paths');
|
||||
const screenshots: ScreenshotEntry[] = rawShots.map((s: unknown) => {
|
||||
if (typeof s === 'string') return { path: s, label: tabLabelFromPath(s) };
|
||||
const obj = s as RecordField;
|
||||
return { path: obj.path || '', label: obj.label || tabLabelFromPath(obj.path || '') };
|
||||
});
|
||||
return {
|
||||
...rec,
|
||||
path: isPathA(rec as Record<string, unknown>) ? 'A' : 'B',
|
||||
screenshots,
|
||||
logs_sent: extractFromRaw(rec as Record<string, unknown>, 'logs_sent_to_llm'),
|
||||
task_logs: [] as unknown[], // will be loaded client-side on demand
|
||||
};
|
||||
});
|
||||
|
||||
return { run };
|
||||
};
|
||||
// #endregion ValidationRunDetailPageLoad
|
||||
@@ -0,0 +1,146 @@
|
||||
// #region TestRunDetailPageHelpers [C:3] [TYPE Module] [SEMANTICS test,frontend,validation,run-detail,helpers]
|
||||
// @BRIEF Verify +page.ts helper functions (extractFromRaw, isPathA) handle all edge cases
|
||||
// including null/undefined raw_response, malformed JSON, and path detection.
|
||||
// @RELATION BINDS_TO -> [ValidationRunDetailPageLoad]
|
||||
// @TEST_EDGE: null_raw_response -> extractFromRaw returns []
|
||||
// @TEST_EDGE: malformed_json_raw_response -> extractFromRaw returns []
|
||||
// @TEST_EDGE: empty_screenshot_paths -> isPathA returns false
|
||||
// @TEST_EDGE: execution_path_screenshot -> isPathA returns true
|
||||
// @TEST_EDGE: execution_path_null_with_screenshots -> isPathA returns true (backward compat)
|
||||
// @TEST_INVARIANT: path_a_requires_screenshots -> VERIFIED_BY: test_is_path_a_with_screenshot_paths, test_is_path_a_with_execution_path_screenshot
|
||||
// @TEST_INVARIANT: path_b_is_default -> VERIFIED_BY: test_is_path_b_default, test_is_path_b_text_only
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ── Pure helper functions (replicated from +page.ts for unit testing) ──
|
||||
|
||||
function extractFromRaw(rec: Record<string, unknown>, field: string): unknown[] {
|
||||
const val = rec[field];
|
||||
if (val && (Array.isArray(val) ? (val as unknown[]).length > 0 : true)) {
|
||||
return val as unknown[];
|
||||
}
|
||||
try {
|
||||
const raw = rec.raw_response;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
|
||||
return (parsed as Record<string, unknown>)[field] as unknown[] || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isPathA(rec: Record<string, unknown>): boolean {
|
||||
if (rec.path === 'A') return true;
|
||||
if (rec.execution_path === 'screenshot' || rec.execution_path === 'multimodal') return true;
|
||||
const shots = extractFromRaw(rec, 'screenshot_paths');
|
||||
return Array.isArray(shots) && shots.length > 0;
|
||||
}
|
||||
|
||||
|
||||
// #region TestExtractFromRaw [C:2] [TYPE Class]
|
||||
// @BRIEF Verify extractFromRaw handles all edge cases for field extraction from records.
|
||||
describe('extractFromRaw', () => {
|
||||
// #region test_returns_top_level_field_when_present [C:2] [TYPE Function]
|
||||
it('returns top-level field when present', () => {
|
||||
const rec = { screenshot_paths: ['/path/a.webp', '/path/b.webp'] };
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual(['/path/a.webp', '/path/b.webp']);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
it('returns non-array top-level field as-is', () => {
|
||||
const rec = { execution_path: 'screenshot' };
|
||||
expect(extractFromRaw(rec, 'execution_path')).toBe('screenshot');
|
||||
});
|
||||
|
||||
it('falls back to raw_response JSON string', () => {
|
||||
const rec = {
|
||||
screenshot_paths: [],
|
||||
raw_response: JSON.stringify({ screenshot_paths: ['/from/raw.webp'] }),
|
||||
};
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual(['/from/raw.webp']);
|
||||
});
|
||||
|
||||
it('falls back to raw_response object', () => {
|
||||
const rec = {
|
||||
screenshot_paths: [],
|
||||
raw_response: { screenshot_paths: ['/from/obj.webp'] },
|
||||
};
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual(['/from/obj.webp']);
|
||||
});
|
||||
|
||||
it('returns empty array for null raw_response', () => {
|
||||
const rec = { raw_response: null };
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for undefined raw_response', () => {
|
||||
const rec = {};
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for malformed JSON', () => {
|
||||
const rec = { raw_response: '{invalid json!!!' };
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back when top-level is empty array', () => {
|
||||
const rec = {
|
||||
screenshot_paths: [],
|
||||
raw_response: JSON.stringify({ screenshot_paths: ['/fallback.webp'] }),
|
||||
};
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual(['/fallback.webp']);
|
||||
});
|
||||
|
||||
it('returns empty when field missing from both locations', () => {
|
||||
const rec = { raw_response: JSON.stringify({ other_field: 'value' }) };
|
||||
expect(extractFromRaw(rec, 'screenshot_paths')).toEqual([]);
|
||||
});
|
||||
});
|
||||
// #endregion TestExtractFromRaw
|
||||
|
||||
|
||||
// #region TestIsPathA [C:2] [TYPE Class]
|
||||
describe('isPathA', () => {
|
||||
it('returns true for execution_path=screenshot', () => {
|
||||
expect(isPathA({ execution_path: 'screenshot' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for execution_path=multimodal', () => {
|
||||
expect(isPathA({ execution_path: 'multimodal' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for path=A', () => {
|
||||
expect(isPathA({ path: 'A' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when screenshot_paths has entries', () => {
|
||||
expect(isPathA({ screenshot_paths: ['/a.webp'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when screenshot_paths in raw_response', () => {
|
||||
expect(isPathA({
|
||||
raw_response: JSON.stringify({ screenshot_paths: ['/from/raw.webp'] }),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for record with no path indicators', () => {
|
||||
expect(isPathA({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for execution_path=text_only', () => {
|
||||
expect(isPathA({ execution_path: 'text_only' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty screenshot_paths', () => {
|
||||
expect(isPathA({ screenshot_paths: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when execution_path is null but screenshots exist', () => {
|
||||
expect(isPathA({
|
||||
execution_path: null,
|
||||
screenshot_paths: ['/old/screenshot.webp'],
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
// #endregion TestIsPathA
|
||||
|
||||
// #endregion TestRunDetailPageHelpers
|
||||
@@ -2,9 +2,24 @@
|
||||
// @BRIEF Load function for the validation history page — fetches runs across all tasks (v2 endpoint).
|
||||
// @RELATION CALLS -> [ApiModule.fetchApi]
|
||||
import { fetchApi } from '$lib/api.js';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ url }) {
|
||||
interface RunsResponse {
|
||||
items?: unknown[];
|
||||
results?: unknown[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface LoadResult {
|
||||
runs: unknown[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
statusFilter?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ url }): Promise<LoadResult> => {
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const page_size = parseInt(url.searchParams.get('page_size') || '20', 10);
|
||||
const status = url.searchParams.get('status') || undefined;
|
||||
@@ -15,7 +30,7 @@ export async function load({ url }) {
|
||||
qs.append('page_size', String(page_size));
|
||||
if (status) qs.append('status', status);
|
||||
|
||||
const result = await fetchApi(`/validation-tasks/runs/all?${qs.toString()}`);
|
||||
const result: RunsResponse = await fetchApi(`/validation-tasks/runs/all?${qs.toString()}`);
|
||||
const runs = result?.items ?? result?.results ?? [];
|
||||
|
||||
return {
|
||||
@@ -25,7 +40,7 @@ export async function load({ url }) {
|
||||
page_size,
|
||||
statusFilter: status,
|
||||
};
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
return {
|
||||
runs: [],
|
||||
total: 0,
|
||||
@@ -34,5 +49,5 @@ export async function load({ url }) {
|
||||
error: e instanceof Error ? e.message : 'Failed to load runs',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
// #endregion ValidationHistoryPageLoad
|
||||
@@ -1,23 +0,0 @@
|
||||
// #region ValidationTaskNewPageLoad [C:2] [TYPE Function] [SEMANTICS validation-tasks,new,load,providers]
|
||||
// @BRIEF Load function for the new validation task page — fetches LLM providers list.
|
||||
// @RELATION CALLS -> [ApiModule.getLlmProviders]
|
||||
// @POST Returns { llmProviders }
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load() {
|
||||
try {
|
||||
const providers = await api.getLlmProviders();
|
||||
return {
|
||||
llmProviders: Array.isArray(providers) ? providers : (providers?.providers ?? providers?.results ?? []),
|
||||
};
|
||||
} catch (e) {
|
||||
log('validation-tasks/new', 'EXPLORE', 'Failed to load providers', {}, e instanceof Error ? e.message : 'unknown');
|
||||
return {
|
||||
llmProviders: [],
|
||||
providersError: /** @type {Error} */ (e).message || 'Failed to load providers',
|
||||
};
|
||||
}
|
||||
}
|
||||
// #endregion ValidationTaskNewPageLoad
|
||||
35
frontend/src/routes/validation-tasks/new/+page.ts
Normal file
35
frontend/src/routes/validation-tasks/new/+page.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// #region ValidationTaskNewPageLoad [C:2] [TYPE Function] [SEMANTICS validation-tasks,new,load,providers]
|
||||
// @BRIEF Load function for the new validation task page — fetches LLM providers list.
|
||||
// @RELATION CALLS -> [ApiModule.getLlmProviders]
|
||||
// @POST Returns { llmProviders }
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
interface ProvidersResponse {
|
||||
providers?: unknown[];
|
||||
results?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface LoadResult {
|
||||
llmProviders: unknown[];
|
||||
providersError?: string;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async (): Promise<LoadResult> => {
|
||||
try {
|
||||
const providers: unknown[] | ProvidersResponse = await api.getLlmProviders();
|
||||
return {
|
||||
llmProviders: Array.isArray(providers) ? providers : ((providers as ProvidersResponse)?.providers ?? (providers as ProvidersResponse)?.results ?? []),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'unknown';
|
||||
log('validation-tasks/new', 'EXPLORE', 'Failed to load providers', {}, message);
|
||||
return {
|
||||
llmProviders: [],
|
||||
providersError: message || 'Failed to load providers',
|
||||
};
|
||||
}
|
||||
};
|
||||
// #endregion ValidationTaskNewPageLoad
|
||||
Reference in New Issue
Block a user