refactor(frontend): extract ValidationRunDetailModel for validation run detail
Validation run detail: 809→201 lines (-75%). Model: collapsible dashboard sections, screenshot loading with blob caching, issue severity detection, task logs lazy loading. Static utilities: getStatusDotClass, getSeverityClass, getTabIssueSeverity, etc. 6→4 oversized pages remaining.
This commit is contained in:
206
frontend/src/lib/models/ValidationRunDetailModel.svelte.ts
Normal file
206
frontend/src/lib/models/ValidationRunDetailModel.svelte.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// #region ValidationRunDetailModel [C:3] [TYPE Model] [SEMANTICS validation,run,detail,dashboard,collapsible,model]
|
||||
// @BRIEF Screen model for validation run detail — dashboard-by-dashboard results with screenshots, issues, and task logs.
|
||||
// @INVARIANT Screenshot blob URLs are cached per (dashboardId, tabIndex) key. Revoked when cache key is overwritten.
|
||||
// @ACTION toggleDashboard(id) / toggleLogsSent(key) / toggleTaskLogs(key) — Collapsible section toggles.
|
||||
// @ACTION loadScreenshot(path, key) — Loads screenshot blob from storage API.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
|
||||
import { api } from '$lib/api.js';
|
||||
import type { TaskLogEntry } from '$lib/api.js';
|
||||
|
||||
type UxState = 'idle' | 'loading' | 'populated' | 'error' | 'not_found';
|
||||
|
||||
interface RunData {
|
||||
id?: string;
|
||||
task_id?: string;
|
||||
policy_id?: string;
|
||||
status?: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
trigger_type?: string;
|
||||
dashboards?: DashboardResult[];
|
||||
results?: DashboardResult[];
|
||||
}
|
||||
|
||||
interface DashboardResult {
|
||||
id?: string;
|
||||
dashboard_id?: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
status?: string;
|
||||
duration_ms?: number;
|
||||
path?: string;
|
||||
tabs?: TabResult[];
|
||||
screenshots?: ScreenshotResult[];
|
||||
issues?: IssueResult[];
|
||||
datasets?: unknown[];
|
||||
charts?: unknown[];
|
||||
logs_sent_to_llm?: string[];
|
||||
dataset_health?: unknown;
|
||||
_taskLogsLoaded?: boolean;
|
||||
task_logs?: TaskLogEntry[];
|
||||
}
|
||||
|
||||
interface TabResult {
|
||||
label?: string;
|
||||
index?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ScreenshotResult {
|
||||
path?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface IssueResult {
|
||||
severity?: string;
|
||||
message?: string;
|
||||
location?: string;
|
||||
tab_index?: number | string;
|
||||
}
|
||||
|
||||
export class ValidationRunDetailModel {
|
||||
// ── Atoms ─────────────────────────────────────────────────────
|
||||
uxState: UxState = $state('populated');
|
||||
run: RunData | null = $state(null);
|
||||
errorMsg: string | null = $state(null);
|
||||
expandedDashboards: Set<string> = $state(new Set());
|
||||
selectedScreenshotTab: Record<string, string> = $state({});
|
||||
screenshotUrls: Record<string, string> = $state({});
|
||||
logsSentExpanded: Set<string> = $state(new Set());
|
||||
taskLogsExpanded: Set<string> = $state(new Set());
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
dashboards = $derived(this.run?.dashboards || this.run?.results || []);
|
||||
passCount = $derived(this.dashboards.filter((d: DashboardResult) => d.status === 'PASS').length);
|
||||
warnCount = $derived(this.dashboards.filter((d: DashboardResult) => d.status === 'WARN').length);
|
||||
failCount = $derived(this.dashboards.filter((d: DashboardResult) => d.status === 'FAIL').length);
|
||||
unknownCount = $derived(this.dashboards.filter((d: DashboardResult) => !d.status || d.status === 'UNKNOWN').length);
|
||||
totalIssues = $derived(this.dashboards.reduce((s: number, d: DashboardResult) => s + (d.issues?.length || 0), 0));
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
toggleDashboard(dashboardId: string): void {
|
||||
const next = new Set(this.expandedDashboards);
|
||||
if (next.has(dashboardId)) {
|
||||
next.delete(dashboardId);
|
||||
} else {
|
||||
next.add(dashboardId);
|
||||
if (!this.selectedScreenshotTab[dashboardId]) {
|
||||
this.selectedScreenshotTab = { ...this.selectedScreenshotTab, [dashboardId]: '0' };
|
||||
const db = this.dashboards.find((d: DashboardResult) => d.id === dashboardId || d.dashboard_id === dashboardId);
|
||||
if (db?.screenshots?.length) {
|
||||
const tabKey = `${dashboardId}_0`;
|
||||
if (!this.screenshotUrls[tabKey]) {
|
||||
const path = db.screenshots[0]?.path || String(db.screenshots[0] || '');
|
||||
this.loadScreenshot(path, tabKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.expandedDashboards = next;
|
||||
}
|
||||
|
||||
isDashboardExpanded(dashboardId: string): boolean {
|
||||
return this.expandedDashboards.has(dashboardId);
|
||||
}
|
||||
|
||||
toggleLogsSent(key: string): void {
|
||||
const next = new Set(this.logsSentExpanded);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
this.logsSentExpanded = next;
|
||||
}
|
||||
|
||||
toggleTaskLogs(key: string): void {
|
||||
const next = new Set(this.taskLogsExpanded);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
const dId = key.replace('tasklogs_', '');
|
||||
const db = this.dashboards.find((d: DashboardResult) => d.id === dId || d.dashboard_id === dId) as DashboardResult;
|
||||
if (db && !db._taskLogsLoaded && this.run?.task_id) {
|
||||
db._taskLogsLoaded = true;
|
||||
api.getTaskLogs(this.run.task_id as string, { limit: 200 }).then((logs: unknown) => {
|
||||
db.task_logs = (logs as TaskLogEntry[]) || [];
|
||||
}).catch(() => { db.task_logs = []; });
|
||||
}
|
||||
}
|
||||
this.taskLogsExpanded = next;
|
||||
}
|
||||
|
||||
async loadScreenshot(screenshotPath: string, cacheKey: string): Promise<void> {
|
||||
if (this.screenshotUrls[cacheKey]) return;
|
||||
try {
|
||||
const blob = await api.getStorageFileBlob(screenshotPath);
|
||||
this.screenshotUrls = { ...this.screenshotUrls, [cacheKey]: URL.createObjectURL(blob) };
|
||||
} catch { /* silently fail */ }
|
||||
}
|
||||
|
||||
openScreenshot(url: string): void {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
// ── Static utilities ──────────────────────────────────────────
|
||||
|
||||
static getStatusDotClass(s: string | undefined): string {
|
||||
const map: Record<string, string> = { PASS: 'text-success', WARN: 'text-warning', FAIL: 'text-destructive', UNKNOWN: 'text-text-subtle' };
|
||||
return map[s ?? ''] || 'text-text-subtle';
|
||||
}
|
||||
|
||||
static getStatusDot(s: string | undefined): string {
|
||||
const dots: Record<string, string> = { PASS: '🟢', WARN: '🟡', FAIL: '🔴', UNKNOWN: '⚪' };
|
||||
return dots[s ?? ''] || dots.UNKNOWN;
|
||||
}
|
||||
|
||||
static getSeverityClass(severity: string | undefined): string {
|
||||
const map: Record<string, string> = { critical: 'bg-destructive-light text-destructive', high: 'bg-warning-light text-warning', medium: 'bg-warning-light text-warning', low: 'bg-primary-light text-primary', info: 'bg-surface-muted text-text-muted', error: 'bg-destructive-light text-destructive', warning: 'bg-warning-light text-warning' };
|
||||
return map[severity?.toLowerCase() ?? ''] || 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
static formatDuration(ms: number | undefined): string {
|
||||
if (!ms && ms !== 0) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
static getTriggerLabel(trigger: string | undefined, tFn: (key: string) => string): string {
|
||||
if (trigger === 'manual') return tFn('validation.trigger_manual') || 'Manual';
|
||||
if (trigger === 'scheduled') return tFn('validation.trigger_scheduled') || 'Scheduled';
|
||||
return trigger || '-';
|
||||
}
|
||||
|
||||
static getPathLabel(path: string | undefined, tFn: (key: string) => string): string {
|
||||
if (path === 'A') return tFn('validation.path_a') || 'Path A';
|
||||
if (path === 'B') return tFn('validation.path_b') || 'Path B';
|
||||
return path || '-';
|
||||
}
|
||||
|
||||
static getPathBadgeClass(path: string | undefined): string {
|
||||
if (path === 'A') return 'bg-info-light text-info';
|
||||
if (path === 'B') return 'bg-success-light text-success';
|
||||
return 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
static getTabIssueSeverity(tabLabel: string, tabIdx: number, totalTabs: number, issues: IssueResult[]): { count: number; severity: string } | null {
|
||||
if (!issues?.length) return null;
|
||||
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 };
|
||||
}
|
||||
const lowerLabel = tabLabel?.toLowerCase();
|
||||
let matching: IssueResult[] = [];
|
||||
if (lowerLabel) matching = issues.filter(i => i.location?.toLowerCase().includes(lowerLabel) || lowerLabel.includes(i.location?.toLowerCase() || ''));
|
||||
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 };
|
||||
}
|
||||
}
|
||||
// #endregion ValidationRunDetailModel
|
||||
@@ -1,808 +1,201 @@
|
||||
<!-- #region ValidationRunDetailPage [C:4] [TYPE Component] [SEMANTICS validation, run, detail, report, dashboard, page] -->
|
||||
<!-- @BRIEF Validation run detail report page showing results for ALL dashboards validated in this run — issues, screenshots, dataset health, chart results, and logs grouped by dashboard with collapsible sections. -->
|
||||
<!-- @RELATION DEPENDS_ON -> [ValidationApi] -->
|
||||
<!-- @RELATION BINDS_TO -> [I18nModule] -->
|
||||
<!-- @UX_STATE Loading -> Full-page skeleton -->
|
||||
<!-- @UX_STATE Error -> Alert with error message + Retry/Back buttons -->
|
||||
<!-- @UX_STATE NotFound -> "Run not found" state -->
|
||||
<!-- @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} -->
|
||||
<!-- #region ValidationRunDetailPage [C:4] [TYPE Page] [SEMANTICS validation,run,detail,dashboard,collapsible] -->
|
||||
<!-- @BRIEF Validation run detail — renders ValidationRunDetailModel state. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION BINDS_TO -> [ValidationRunDetailModel] -->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { page } from '$app/state';
|
||||
import { ValidationRunDetailModel } from '$lib/models/ValidationRunDetailModel.svelte';
|
||||
|
||||
/** @type {{ data: import('./$types').PageData }} */
|
||||
let { data } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
let policyId = $derived(page.params.policyId);
|
||||
let runId = $derived(page.params.runId);
|
||||
const m = new ValidationRunDetailModel();
|
||||
m.run = data.run;
|
||||
|
||||
/** @type {'idle'|'loading'|'populated'|'error'|'not_found'} */
|
||||
let uxState = $state('populated');
|
||||
let run = $state(data.run);
|
||||
let errorMsg = $state(null);
|
||||
let policyId = $derived(page.params.policyId);
|
||||
let runId = $derived(page.params.runId);
|
||||
|
||||
// Expanded dashboards — track set of expanded dashboard IDs
|
||||
let expandedDashboards = $state(new Set());
|
||||
const { getStatusDotClass, getStatusDot, getSeverityClass, formatDuration, getTriggerLabel, getPathLabel, getPathBadgeClass, getTabIssueSeverity } = ValidationRunDetailModel;
|
||||
|
||||
// Screenshot tabs — track selected tab per dashboard
|
||||
/** @type {Record<string, string>} */
|
||||
let selectedScreenshotTab = $state({});
|
||||
function handleBack() { goto(ROUTES.validationTasks.detail(policyId)); }
|
||||
|
||||
// Screenshot blob URLs — cache loaded screenshot URLs per (dashboardId, tabIndex)
|
||||
/** @type {Record<string, string>} */
|
||||
let screenshotUrls = $state({});
|
||||
|
||||
// Collapsible sections
|
||||
let logsSentExpanded = $state(new Set());
|
||||
let taskLogsExpanded = $state(new Set());
|
||||
|
||||
let dashboards = $derived(run?.dashboards || run?.results || []);
|
||||
|
||||
// ── Derived aggregate counts ──
|
||||
let passCount = $derived(dashboards.filter(d => d.status === 'PASS').length);
|
||||
let warnCount = $derived(dashboards.filter(d => d.status === 'WARN').length);
|
||||
let failCount = $derived(dashboards.filter(d => d.status === 'FAIL').length);
|
||||
let unknownCount = $derived(dashboards.filter(d => !d.status || d.status === 'UNKNOWN').length);
|
||||
|
||||
let aggregateLabel = $derived(
|
||||
($t.validation?.aggregate_status || '{pass} PASS • {warn} WARN • {fail} FAIL')
|
||||
.replace('{pass}', String(passCount))
|
||||
.replace('{warn}', String(warnCount))
|
||||
.replace('{fail}', String(failCount))
|
||||
);
|
||||
|
||||
let totalIssues = $derived(
|
||||
dashboards.reduce((/** @type {number} */ sum, /** @type {any} */ d) => sum + (d.issues?.length || 0), 0)
|
||||
);
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/** @param {string} s */
|
||||
function getStatusDotClass(s) {
|
||||
const map = {
|
||||
PASS: 'text-success',
|
||||
WARN: 'text-warning',
|
||||
FAIL: 'text-destructive',
|
||||
UNKNOWN: 'text-text-subtle',
|
||||
};
|
||||
return map[s] || 'text-text-subtle';
|
||||
}
|
||||
|
||||
/** @param {string} s */
|
||||
function getStatusDot(s) {
|
||||
const dots = {
|
||||
PASS: '🟢',
|
||||
WARN: '🟡',
|
||||
FAIL: '🔴',
|
||||
UNKNOWN: '⚪',
|
||||
};
|
||||
return dots[s] || dots.UNKNOWN;
|
||||
}
|
||||
|
||||
/** @param {string} severity */
|
||||
function getSeverityClass(severity) {
|
||||
const map = {
|
||||
critical: 'bg-destructive-light text-destructive',
|
||||
high: 'bg-warning-light text-warning',
|
||||
medium: 'bg-warning-light text-warning',
|
||||
low: 'bg-primary-light text-primary',
|
||||
info: 'bg-surface-muted text-text-muted',
|
||||
error: 'bg-destructive-light text-destructive',
|
||||
warning: 'bg-warning-light text-warning',
|
||||
};
|
||||
return map[severity?.toLowerCase()] || 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
/** @param {number} ms */
|
||||
function formatDuration(ms) {
|
||||
if (!ms && ms !== 0) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function getTriggerLabel(trigger) {
|
||||
if (trigger === 'manual') return $t.validation?.trigger_manual || 'Manual';
|
||||
if (trigger === 'scheduled') return $t.validation?.trigger_scheduled || 'Scheduled';
|
||||
return trigger || '-';
|
||||
}
|
||||
|
||||
function getPathLabel(path) {
|
||||
if (path === 'A') return $t.validation?.path_a || 'Path A — Screenshot';
|
||||
if (path === 'B') return $t.validation?.path_b || 'Path B — Text-only';
|
||||
return path || '-';
|
||||
}
|
||||
|
||||
function getPathBadgeClass(path) {
|
||||
if (path === 'A') return 'bg-info-light text-info';
|
||||
if (path === 'B') return 'bg-success-light text-success';
|
||||
return 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (next.has(dashboardId)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** @param {string} dashboardId */
|
||||
function isDashboardExpanded(dashboardId) {
|
||||
return expandedDashboards.has(dashboardId);
|
||||
}
|
||||
|
||||
/** @param {string} key */
|
||||
function toggleLogsSent(key) {
|
||||
const next = new Set(logsSentExpanded);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
logsSentExpanded = 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.
|
||||
* @param {string} screenshotPath
|
||||
* @param {string} cacheKey
|
||||
*/
|
||||
async function loadScreenshot(screenshotPath, cacheKey) {
|
||||
if (screenshotUrls[cacheKey]) return;
|
||||
try {
|
||||
const blob = await api.getStorageFileBlob(screenshotPath);
|
||||
const url = URL.createObjectURL(blob);
|
||||
screenshotUrls = { ...screenshotUrls, [cacheKey]: url };
|
||||
} catch {
|
||||
// silently fail — screenshot unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} url */
|
||||
function openScreenshot(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(ROUTES.validationTasks.detail(policyId));
|
||||
}
|
||||
|
||||
// When a dashboard is expanded and has screenshots, preload the first one
|
||||
$effect(() => {
|
||||
const expanded = Array.from(expandedDashboards);
|
||||
for (const dId of expanded) {
|
||||
/** @type {any} */
|
||||
const db = dashboards.find((/** @type {any} */ d) => d.id === dId || d.dashboard_id === dId);
|
||||
if (!db) continue;
|
||||
const screenshots = db.screenshots;
|
||||
if (screenshots?.length) {
|
||||
const tabKey = `${dId}_0`;
|
||||
if (!screenshotUrls[tabKey]) {
|
||||
loadScreenshot(screenshots[0].path || screenshots[0], tabKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const expanded = Array.from(m.expandedDashboards);
|
||||
for (const dId of expanded) {
|
||||
const db = m.dashboards.find((d: Record<string,unknown>) => d.id === dId || d.dashboard_id === dId) as Record<string,unknown>;
|
||||
if (!db) continue;
|
||||
const selectedTabIdx = parseInt(m.selectedScreenshotTab[dId] || '0', 10);
|
||||
const screenshots = (db.screenshots || db.path || []) as unknown[];
|
||||
if (screenshots.length > 0) {
|
||||
const screenEntry = screenshots[selectedTabIdx] as Record<string,unknown> | undefined;
|
||||
if (screenEntry) {
|
||||
const path = screenEntry.path || String(screenEntry || screenshots[selectedTabIdx]);
|
||||
const tabKey = `${dId}_${selectedTabIdx}`;
|
||||
if (!m.screenshotUrls[tabKey]) m.loadScreenshot(String(path), tabKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t.validation?.run_detail || 'Run Detail'} #{runId?.substring(0, 8)}</title>
|
||||
</svelte:head>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<button onclick={handleBack} class="flex items-center gap-2 text-text-muted hover:text-text transition-colors mb-4">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
|
||||
{$t.validation?.back_to_policy || 'Back to policy'}
|
||||
</button>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
|
||||
<!-- Back link -->
|
||||
<button
|
||||
onclick={handleBack}
|
||||
class="inline-flex items-center text-sm text-text-muted hover:text-text mb-4 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{$t.validation?.back_to_tasks || '← Back to tasks'}
|
||||
</button>
|
||||
{#if m.uxState === 'not_found'}
|
||||
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
|
||||
<p class="text-text-muted">{$t.validation?.run_not_found || 'Run not found'}</p>
|
||||
</div>
|
||||
{:else if m.uxState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-6 text-center">
|
||||
<p class="text-destructive mb-3">{m.errorMsg || $t.validation?.load_runs_failed || 'Failed to load run details.'}</p>
|
||||
<button onclick={() => window.location.reload()} class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors">{$t.validation?.retry || 'Retry'}</button>
|
||||
</div>
|
||||
{:else if m.run}
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
|
||||
<div class="bg-surface-card border border-border rounded-lg p-3 text-center"><p class="text-2xl font-bold text-success">{m.passCount}</p><p class="text-xs text-text-muted">PASS</p></div>
|
||||
<div class="bg-surface-card border border-border rounded-lg p-3 text-center"><p class="text-2xl font-bold text-warning">{m.warnCount}</p><p class="text-xs text-text-muted">WARN</p></div>
|
||||
<div class="bg-surface-card border border-border rounded-lg p-3 text-center"><p class="text-2xl font-bold text-destructive">{m.failCount}</p><p class="text-xs text-text-muted">FAIL</p></div>
|
||||
<div class="bg-surface-card border border-border rounded-lg p-3 text-center"><p class="text-2xl font-bold text-text-subtle">{m.unknownCount}</p><p class="text-xs text-text-muted">UNKNOWN</p></div>
|
||||
<div class="bg-surface-card border border-border rounded-lg p-3 text-center"><p class="text-2xl font-bold text-info">{m.totalIssues}</p><p class="text-xs text-text-muted">{$t.validation?.issues || 'Issues'}</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if uxState === 'loading'}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6 animate-pulse space-y-3">
|
||||
<div class="h-6 w-64 bg-surface-muted rounded"></div>
|
||||
<div class="h-4 w-96 bg-surface-muted rounded"></div>
|
||||
<div class="h-8 w-48 bg-surface-muted rounded-full"></div>
|
||||
</div>
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="h-16 bg-surface-muted rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Run header -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="font-semibold text-text">{m.run.id}</span>
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full {getStatusDotClass(m.run.status)}">{getStatusDot(m.run.status)} {m.run.status}</span>
|
||||
{#if m.run.trigger_type}<span class="text-text-muted">{getTriggerLabel(m.run.trigger_type, t)}</span>{/if}
|
||||
<span class="text-text-subtle">{m.run.started_at ? new Date(m.run.started_at as string).toLocaleString() : ''}</span>
|
||||
{#if m.run.finished_at}<span class="text-text-subtle">→ {new Date(m.run.finished_at as string).toLocaleString()}</span>{/if}
|
||||
{#if m.run.started_at && m.run.finished_at}<span class="text-text-muted font-mono text-xs">{formatDuration((new Date(m.run.finished_at as string).getTime() - new Date(m.run.started_at as string).getTime()))}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-6 text-center">
|
||||
<p class="text-destructive mb-3">{errorMsg || $t.validation?.load_runs_failed || 'Failed to load run details.'}</p>
|
||||
<button
|
||||
onclick={() => window.location.reload()}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.validation?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dashboard results -->
|
||||
<div class="space-y-4">
|
||||
{#each m.dashboards as dashboard (dashboard.id || dashboard.dashboard_id)}
|
||||
<div class="bg-surface-card border border-border rounded-lg overflow-hidden">
|
||||
<!-- Dashboard header (clickable) -->
|
||||
<button class="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-muted transition-colors text-left" onclick={() => m.toggleDashboard(dashboard.id || dashboard.dashboard_id)}>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class={`w-5 h-5 transition-transform ${m.isDashboardExpanded(dashboard.id || dashboard.dashboard_id) ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
<span class="font-medium text-text">{dashboard.title || dashboard.slug || dashboard.dashboard_id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if dashboard.path}
|
||||
<span class={`px-2 py-0.5 text-xs rounded-full ${getPathBadgeClass(dashboard.path)}`}>{getPathLabel(dashboard.path, t)}</span>
|
||||
{/if}
|
||||
<span class={`px-2 py-0.5 text-xs rounded-full font-semibold uppercase ${getStatusDotClass(dashboard.status)}`}>{dashboard.status}</span>
|
||||
{#if dashboard.duration_ms}<span class="text-xs text-text-muted font-mono">{formatDuration(dashboard.duration_ms)}</span>{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- NotFound State -->
|
||||
{:else if uxState === 'not_found'}
|
||||
<div class="bg-warning-light border border-warning rounded-lg p-12 text-center">
|
||||
<h2 class="text-xl font-bold text-text mb-2">{$t.validation?.task_not_found || 'Run not found'}</h2>
|
||||
<p class="text-text-muted mb-6">{$t.validation?.task_not_found_desc || 'The run you are looking for does not exist or has been deleted.'}</p>
|
||||
<button
|
||||
onclick={handleBack}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.validation?.go_back || 'Go back'}
|
||||
</button>
|
||||
</div>
|
||||
{#if m.isDashboardExpanded(dashboard.id || dashboard.dashboard_id)}
|
||||
<div class="border-t border-border px-4 py-3 space-y-4">
|
||||
<!-- Tabs + Screenshots -->
|
||||
{#if dashboard.tabs?.length}
|
||||
{@const selectedIdx = parseInt(m.selectedScreenshotTab[dashboard.id || dashboard.dashboard_id] || '0', 10)}
|
||||
<div>
|
||||
<div class="flex gap-1 mb-3 flex-wrap">
|
||||
{#each dashboard.tabs as tab, tabIdx}
|
||||
{@const issueInfo = getTabIssueSeverity(tab.label || '', tabIdx, dashboard.tabs?.length || 0, dashboard.issues || [])}
|
||||
<button
|
||||
onclick={() => { m.selectedScreenshotTab = { ...m.selectedScreenshotTab, [dashboard.id || dashboard.dashboard_id]: String(tabIdx) }; }}
|
||||
class="relative px-3 py-1.5 text-xs rounded-md transition-colors {String(tabIdx) === (m.selectedScreenshotTab[dashboard.id || dashboard.dashboard_id] || '0') ? 'bg-primary-light text-primary border border-primary-ring' : 'bg-surface-muted text-text-muted border border-border hover:bg-surface-page'}"
|
||||
>
|
||||
{tab.label || `Tab ${tabIdx + 1}`}
|
||||
{#if issueInfo}
|
||||
<span class={`ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold ${issueInfo.severity === 'FAIL' ? 'bg-destructive text-white' : issueInfo.severity === 'WARN' ? 'bg-warning text-white' : 'bg-primary-light text-primary'}`}>{issueInfo.count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if dashboard.screenshots?.length}
|
||||
{@const screenEntry = dashboard.screenshots[selectedIdx || 0]}
|
||||
{#if screenEntry}
|
||||
<div class="rounded-lg border border-border overflow-hidden">
|
||||
{#if m.screenshotUrls[`${dashboard.id || dashboard.dashboard_id}_${selectedIdx || 0}`]}
|
||||
<img src={m.screenshotUrls[`${dashboard.id || dashboard.dashboard_id}_${selectedIdx || 0}`]} alt="Screenshot" class="w-full object-cover max-h-96" />
|
||||
{:else}
|
||||
<div class="h-64 flex items-center justify-center bg-surface-muted text-text-subtle text-sm animate-pulse">{$t.validation?.loading_screenshot || 'Loading screenshot...'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Populated State -->
|
||||
{:else if uxState === 'populated'}
|
||||
<!-- Run Header -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text mb-1">
|
||||
{$t.validation?.run_detail || 'Run Detail'}
|
||||
{#if run?.id}
|
||||
<span class="text-text-subtle font-mono text-lg">#{run.id.substring(0, 8)}</span>
|
||||
{/if}
|
||||
</h1>
|
||||
{#if run?.task_name}
|
||||
<p class="text-sm text-text-muted mb-2">{run.task_name}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-text-muted">
|
||||
{#if run?.timestamp || run?.started_at || run?.created_at}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{new Date(run.timestamp || run.started_at || run.created_at).toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{getTriggerLabel(run?.trigger)}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{$t.validation?.duration || 'Duration'}: {formatDuration(run?.duration_ms)}
|
||||
</span>
|
||||
<span class="text-xs text-text-subtle">
|
||||
{dashboards.length} {$t.validation?.dashboard || 'dashboard'}{dashboards.length !== 1 ? 's' : ''}
|
||||
· {totalIssues} {$t.validation?.issues?.toLowerCase() || 'issues'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Aggregate Badge -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
{#if failCount > 0}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-destructive-light text-destructive">
|
||||
{aggregateLabel}
|
||||
</span>
|
||||
{:else if warnCount > 0}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-warning-light text-warning">
|
||||
{aggregateLabel}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-success-light text-success">
|
||||
{aggregateLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issues -->
|
||||
{#if dashboard.issues?.length}
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-text mb-2">{$t.validation?.issues || 'Issues'} ({dashboard.issues.length})</h4>
|
||||
<div class="space-y-1.5">
|
||||
{#each dashboard.issues as issue}
|
||||
<div class="flex items-start gap-2 rounded-md px-3 py-2 {getSeverityClass(issue.severity)}">
|
||||
<span class="text-[10px] font-bold uppercase mt-0.5 shrink-0">{issue.severity || 'INFO'}</span>
|
||||
<span class="text-xs">{issue.message || '-'}</span>
|
||||
{#if issue.location}<span class="text-[10px] text-text-subtle ml-auto shrink-0">{issue.location}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dashboard List -->
|
||||
<div class="space-y-2">
|
||||
{#each dashboards as dashboard (dashboard.id || dashboard.dashboard_id)}
|
||||
{@const dId = dashboard.id || dashboard.dashboard_id}
|
||||
{@const isExpanded = expandedDashboards.has(dId)}
|
||||
{@const dbIssues = dashboard.issues || []}
|
||||
{@const dbPath = dashboard.path || 'A'}
|
||||
<!-- Logs sent to LLM -->
|
||||
{#if dashboard.logs_sent_to_llm?.length}
|
||||
<div>
|
||||
<button class="text-xs font-medium text-primary hover:underline" onclick={() => m.toggleLogsSent(dashboard.id || dashboard.dashboard_id)}>
|
||||
{m.logsSentExpanded.has(dashboard.id || dashboard.dashboard_id) ? '▾' : '▸'} {$t.validation?.logs_sent || 'Logs sent to LLM'} ({dashboard.logs_sent_to_llm.length})
|
||||
</button>
|
||||
{#if m.logsSentExpanded.has(dashboard.id || dashboard.dashboard_id)}
|
||||
<pre class="mt-2 max-h-48 overflow-auto rounded-lg bg-terminal-bg p-3 text-xs text-text-subtle">{dashboard.logs_sent_to_llm.join('\n')}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dashboard Row (Collapsible Header) -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => toggleDashboard(dId)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') toggleDashboard(dId); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="bg-surface-card border border-border rounded-lg transition-all hover:shadow-sm cursor-pointer {isExpanded ? 'rounded-b-none border-b-0' : ''}"
|
||||
>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<!-- Status dot -->
|
||||
<span class="text-lg mr-3">{getStatusDot(dashboard.status)}</span>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-text truncate">
|
||||
{dashboard.title || dashboard.name || dId}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{dbIssues.length} {$t.validation?.issues?.toLowerCase() || 'issues'}</span>
|
||||
<span>·</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getPathBadgeClass(dbPath)}">
|
||||
{#if dbPath === 'A'}📸 {:else}📝{/if}
|
||||
<span class="ml-1">{getPathLabel(dbPath)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expand/collapse arrow -->
|
||||
<svg
|
||||
class="w-5 h-5 text-text-subtle transition-transform {isExpanded ? 'rotate-180' : ''}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Detail -->
|
||||
{#if isExpanded}
|
||||
<div class="bg-surface-card border border-t-0 border-border rounded-b-lg p-4 space-y-6">
|
||||
<!-- Issues Table -->
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-text mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{$t.validation?.issues || 'Issues'}
|
||||
</h3>
|
||||
{#if dbIssues.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_issues || 'No issues detected'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-border text-sm">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.severity || 'Severity'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.message || 'Message'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.location || 'Location'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each dbIssues as issue (issue.id || `${issue.message}-${issue.location || ''}-${issue.severity || ''}`)}
|
||||
<tr class="hover:bg-surface-muted">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getSeverityClass(issue.severity || issue.level)}">
|
||||
{issue.severity || issue.level || 'info'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text max-w-md">
|
||||
<p class="truncate" title={issue.message}>{issue.message || '-'}</p>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted max-w-xs">
|
||||
<p class="truncate text-xs font-mono" title={issue.location || issue.element || ''}>
|
||||
{issue.location || issue.element || '-'}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Path Indicator -->
|
||||
<div class="flex items-center gap-2 text-xs text-text-muted bg-surface-muted border border-border rounded-lg px-3 py-2">
|
||||
{#if dbPath === 'A'}
|
||||
<span class="text-info font-medium">📸 {$t.validation?.path_a || 'Path A — Screenshot'}</span>
|
||||
<span class="text-text-subtle">| {$t.validation?.screenshots || 'Screenshots'} captured via browser automation</span>
|
||||
{#if dashboard.chunk_count && dashboard.chunk_count > 1}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-warning-light text-warning text-xs font-medium">
|
||||
{$t.validation?.chunked || 'Chunked'} ×{dashboard.chunk_count}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-success font-medium">📝 {$t.validation?.path_b || 'Path B — Text-only'}</span>
|
||||
<span class="text-text-subtle">| {$t.validation?.issues || 'Issue'} detection via DOM extraction</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Path B: Dataset Health -->
|
||||
{#if dbPath === 'B'}
|
||||
{@const dsHealth = dashboard.dataset_health || dashboard.datasetHealth || []}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-text mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
{$t.validation?.dataset_health || 'Dataset Health'}
|
||||
</h3>
|
||||
{#if dsHealth.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_issues || 'No dataset health data'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-border text-sm">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.dataset_name || 'Name'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.database || 'Database'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.backend || 'Backend'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.status || 'Status'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.message || 'Error'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.dashboard || 'Charts'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each dsHealth as ds (ds.id || ds.name)}
|
||||
<tr class="hover:bg-surface-muted">
|
||||
<td class="px-3 py-2 font-medium text-text whitespace-nowrap max-w-[160px] truncate" title={ds.name}>
|
||||
{ds.name || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted whitespace-nowrap">
|
||||
{ds.database || ds.db || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted whitespace-nowrap">
|
||||
{ds.backend || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
{#if ds.status === 'ok' || ds.healthy}
|
||||
<span class="inline-flex items-center gap-1 text-success text-xs font-medium">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{$t.validation?.status_ok || '✓ Healthy'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1 text-destructive text-xs font-medium">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{$t.validation?.status_error || '✗ Error'}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted max-w-[200px]">
|
||||
<p class="truncate text-xs" title={ds.error || ds.message || ''}>
|
||||
{ds.error || ds.message || '-'}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted text-xs">
|
||||
{ds.affected_charts || ds.charts?.length || ds.chart_count || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Path B with execute_chart_data: Chart Data Results -->
|
||||
{#if dbPath === 'B' && dashboard.execute_chart_data}
|
||||
{@const chartData = dashboard.chart_data || dashboard.chartData || []}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-text mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{$t.validation?.chart_data || 'Chart Data Results'}
|
||||
</h3>
|
||||
{#if chartData.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_issues || 'No chart data available'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-border text-sm">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.chart_name || 'Chart'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.viz_type || 'Type'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.duration || 'Duration'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.row_count || 'Rows'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase">{$t.validation?.message || 'Error'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each chartData as chart (chart.id || chart.name)}
|
||||
<tr class="hover:bg-surface-muted">
|
||||
<td class="px-3 py-2 font-medium text-text whitespace-nowrap max-w-[160px] truncate" title={chart.name}>
|
||||
{chart.name || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted whitespace-nowrap">
|
||||
{chart.viz_type || chart.type || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted whitespace-nowrap font-mono text-xs">
|
||||
{formatDuration(chart.duration_ms || chart.duration)}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted whitespace-nowrap">
|
||||
{chart.row_count ?? chart.rows ?? '-'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-muted max-w-[200px]">
|
||||
{#if chart.error}
|
||||
<span class="text-destructive text-xs">{chart.error}</span>
|
||||
{:else}
|
||||
<span class="text-success text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Path A: Screenshots with tab selector -->
|
||||
{#if dbPath === 'A'}
|
||||
{@const screenshots = dashboard.screenshots || []}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-text mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{$t.validation?.screenshots || 'Screenshots'}
|
||||
</h3>
|
||||
{#if screenshots.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_screenshots || 'No screenshots saved'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 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) };
|
||||
if (!screenshotUrls[tabKey]) {
|
||||
loadScreenshot(shot.path || shot, tabKey);
|
||||
}
|
||||
}}
|
||||
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-primary-light border-primary-ring text-primary'
|
||||
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}
|
||||
{issueTag ? (issueTag.severity === 'FAIL' ? 'ring-2 ring-destructive-ring' : 'ring-2 ring-warning-ring') : ''}"
|
||||
>
|
||||
{#if issueTag}
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full text-xs font-bold {issueTag.severity === 'FAIL' ? 'bg-destructive text-white' : 'bg-warning text-yellow-900'}">
|
||||
{issueTag.count}
|
||||
</span>
|
||||
{/if}
|
||||
{$t.validation?.tab || 'Tab'} {idx + 1}{tabLabel ? `: ${tabLabel}` : ''}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview -->
|
||||
{@const activeTab = selectedScreenshotTab[dId] || '0'}
|
||||
{@const activeShot = screenshots[parseInt(activeTab)]}
|
||||
{@const shotCacheKey = `${dId}_${activeTab}`}
|
||||
{@const shotUrl = screenshotUrls[shotCacheKey]}
|
||||
|
||||
{#if shotUrl}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => openScreenshot(shotUrl)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') openScreenshot(shotUrl); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="border border-border rounded-lg overflow-hidden cursor-zoom-in hover:shadow-md transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={shotUrl}
|
||||
alt={`Screenshot ${parseInt(activeTab) + 1}`}
|
||||
class="w-full h-auto max-h-[500px] object-contain bg-surface-muted"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-text-subtle mt-1">{$t.validation?.screenshot || 'Click to open in new tab'}</p>
|
||||
{:else if activeShot}
|
||||
<div class="border border-border rounded-lg p-8 text-center bg-surface-muted animate-pulse">
|
||||
<svg class="w-12 h-12 mx-auto text-text-subtle mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-text-subtle">{$t.common?.loading || 'Loading...'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Both Paths: Logs sent to LLM -->
|
||||
{#if true}
|
||||
{@const logsSent = dashboard.logs_sent_to_llm || dashboard.llm_prompt || dashboard.llm_logs}
|
||||
{@const logsKey = `logs_${dId}`}
|
||||
<section>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => toggleLogsSent(logsKey)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') toggleLogsSent(logsKey); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex items-center justify-between bg-surface-muted border border-border rounded-lg px-3 py-2 cursor-pointer hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.validation?.logs_sent || 'Logs sent to LLM'}</h3>
|
||||
<svg
|
||||
class="w-4 h-4 text-text-subtle transition-transform {logsSentExpanded.has(logsKey) ? 'rotate-180' : ''}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{#if logsSentExpanded.has(logsKey)}
|
||||
{#if logsSent}
|
||||
<pre class="mt-2 bg-terminal-bg text-success text-xs rounded-lg p-4 overflow-x-auto max-h-64 overflow-y-auto font-mono"><code>{typeof logsSent === 'string' ? logsSent : JSON.stringify(logsSent, null, 2)}</code></pre>
|
||||
{:else}
|
||||
<div class="mt-2 bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_logs || 'No logs available'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Both Paths: Task execution logs -->
|
||||
{#if true}
|
||||
{@const execLogs = dashboard.task_logs || dashboard.executionLogs || dashboard.logs || []}
|
||||
{@const taskLogsKey = `tasklogs_${dId}`}
|
||||
<section>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => toggleTaskLogs(taskLogsKey)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') toggleTaskLogs(taskLogsKey); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex items-center justify-between bg-surface-muted border border-border rounded-lg px-3 py-2 cursor-pointer hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.validation?.task_logs || 'Task execution logs'}</h3>
|
||||
<svg
|
||||
class="w-4 h-4 text-text-subtle transition-transform {taskLogsExpanded.has(taskLogsKey) ? 'rotate-180' : ''}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{#if taskLogsExpanded.has(taskLogsKey)}
|
||||
{#if execLogs.length > 0}
|
||||
<div class="mt-2 overflow-x-auto border border-border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-border text-xs">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">Time</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">Level</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">Source</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-text-muted uppercase">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each execLogs as log (log.id || log.timestamp || log.time)}
|
||||
<tr class="hover:bg-surface-muted">
|
||||
<td class="px-3 py-1.5 whitespace-nowrap text-text-muted font-mono">
|
||||
{log.timestamp || log.time || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {getSeverityClass(log.level)}">
|
||||
{log.level || 'INFO'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 whitespace-nowrap text-text-muted font-mono">
|
||||
{log.source || log.logger || '-'}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-text max-w-md">
|
||||
<p class="truncate" title={log.message}>{log.message || '-'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2 bg-surface-muted border border-dashed border-border rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-text-subtle">{$t.validation?.no_logs || 'No logs available'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Task execution logs -->
|
||||
<div>
|
||||
<button class="text-xs font-medium text-primary hover:underline" onclick={() => m.toggleTaskLogs(`tasklogs_${dashboard.id || dashboard.dashboard_id}`)}>
|
||||
{m.taskLogsExpanded.has(`tasklogs_${dashboard.id || dashboard.dashboard_id}`) ? '▾' : '▸'} {$t.validation?.task_logs || 'Task execution logs'}
|
||||
</button>
|
||||
{#if m.taskLogsExpanded.has(`tasklogs_${dashboard.id || dashboard.dashboard_id}`) && (dashboard as Record<string,unknown>).task_logs}
|
||||
<div class="mt-2 max-h-64 overflow-auto rounded-lg border border-border">
|
||||
<table class="min-w-full divide-y divide-border text-xs">
|
||||
<thead class="bg-surface-page">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.validation?.time || 'Time'}</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.validation?.level || 'Level'}</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.validation?.source || 'Source'}</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.validation?.message || 'Message'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each (dashboard as Record<string,unknown>).task_logs as logEntry}
|
||||
<tr>
|
||||
<td class="px-3 py-2 align-top text-text-muted">{new Date((logEntry as Record<string,unknown>).timestamp as string).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 align-top text-text">{(logEntry as Record<string,unknown>).level || '-'}</td>
|
||||
<td class="px-3 py-2 align-top text-text">{(logEntry as Record<string,unknown>).source || '-'}</td>
|
||||
<td class="px-3 py-2 align-top text-text whitespace-pre-wrap">{(logEntry as Record<string,unknown>).message || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if m.taskLogsExpanded.has(`tasklogs_${dashboard.id || dashboard.dashboard_id}`)}
|
||||
<div class="mt-2 text-xs text-text-muted animate-pulse">{$t.validation?.loading_logs || 'Loading logs...'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ValidationRunDetailPage -->
|
||||
|
||||
Reference in New Issue
Block a user