// #region ValidationTasks.RunDetailModel [C:3] [TYPE Model] [SEMANTICS validation,run,detail,dashboard,collapsible,model] // @ingroup ValidationTasks // @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 -> [ApiModule] import { api } from '$lib/api.js'; import type { TaskLogEntry } from '$lib/api.js'; import { SvelteSet } from "svelte/reactivity"; 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 = $state(new SvelteSet()); selectedScreenshotTab: Record = $state({}); screenshotUrls: Record = $state({}); logsSentExpanded: Set = $state(new SvelteSet()); taskLogsExpanded: Set = $state(new SvelteSet()); // ── 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 SvelteSet(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 SvelteSet(this.logsSentExpanded); next.has(key) ? next.delete(key) : next.add(key); this.logsSentExpanded = next; } toggleTaskLogs(key: string): void { const next = new SvelteSet(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 { 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 = { 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 = { PASS: '🟢', WARN: '🟡', FAIL: '🔴', UNKNOWN: '⚪' }; return dots[s ?? ''] || dots.UNKNOWN; } static getSeverityClass(severity: string | undefined): string { const map: Record = { 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 ValidationTasks.RunDetailModel