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:
2026-06-02 18:30:08 +03:00
parent ded453944d
commit 463a8dd1bb
2 changed files with 389 additions and 790 deletions

View 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

View File

@@ -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 -->