diff --git a/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts new file mode 100644 index 00000000..f0313659 --- /dev/null +++ b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts @@ -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 = $state(new Set()); + selectedScreenshotTab: Record = $state({}); + screenshotUrls: Record = $state({}); + logsSentExpanded: Set = $state(new Set()); + taskLogsExpanded: Set = $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 { + 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 ValidationRunDetailModel diff --git a/frontend/src/routes/validation-tasks/[policyId]/runs/[runId]/+page.svelte b/frontend/src/routes/validation-tasks/[policyId]/runs/[runId]/+page.svelte index d85ad288..7588f709 100644 --- a/frontend/src/routes/validation-tasks/[policyId]/runs/[runId]/+page.svelte +++ b/frontend/src/routes/validation-tasks/[policyId]/runs/[runId]/+page.svelte @@ -1,808 +1,201 @@ - - - - - - - - - - - - - + + + + - - {$t.validation?.run_detail || 'Run Detail'} #{runId?.substring(0, 8)} - +
+ -
- - + {#if m.uxState === 'not_found'} +
+

{$t.validation?.run_not_found || 'Run not found'}

+
+ {:else if m.uxState === 'error'} +
+

{m.errorMsg || $t.validation?.load_runs_failed || 'Failed to load run details.'}

+ +
+ {:else if m.run} + +
+

{m.passCount}

PASS

+

{m.warnCount}

WARN

+

{m.failCount}

FAIL

+

{m.unknownCount}

UNKNOWN

+

{m.totalIssues}

{$t.validation?.issues || 'Issues'}

+
- - {#if uxState === 'loading'} -
-
-
-
-
-
- {#each Array(3) as _, i (i)} -
- {/each} -
+ +
+
+ {m.run.id} + {getStatusDot(m.run.status)} {m.run.status} + {#if m.run.trigger_type}{getTriggerLabel(m.run.trigger_type, t)}{/if} + {m.run.started_at ? new Date(m.run.started_at as string).toLocaleString() : ''} + {#if m.run.finished_at}→ {new Date(m.run.finished_at as string).toLocaleString()}{/if} + {#if m.run.started_at && m.run.finished_at}{formatDuration((new Date(m.run.finished_at as string).getTime() - new Date(m.run.started_at as string).getTime()))}{/if} +
+
- - {:else if uxState === 'error'} -
-

{errorMsg || $t.validation?.load_runs_failed || 'Failed to load run details.'}

- -
+ +
+ {#each m.dashboards as dashboard (dashboard.id || dashboard.dashboard_id)} +
+ + - - {:else if uxState === 'not_found'} -
-

{$t.validation?.task_not_found || 'Run not found'}

-

{$t.validation?.task_not_found_desc || 'The run you are looking for does not exist or has been deleted.'}

- -
+ {#if m.isDashboardExpanded(dashboard.id || dashboard.dashboard_id)} +
+ + {#if dashboard.tabs?.length} + {@const selectedIdx = parseInt(m.selectedScreenshotTab[dashboard.id || dashboard.dashboard_id] || '0', 10)} +
+
+ {#each dashboard.tabs as tab, tabIdx} + {@const issueInfo = getTabIssueSeverity(tab.label || '', tabIdx, dashboard.tabs?.length || 0, dashboard.issues || [])} + + {/each} +
+ {#if dashboard.screenshots?.length} + {@const screenEntry = dashboard.screenshots[selectedIdx || 0]} + {#if screenEntry} +
+ {#if m.screenshotUrls[`${dashboard.id || dashboard.dashboard_id}_${selectedIdx || 0}`]} + Screenshot + {:else} +
{$t.validation?.loading_screenshot || 'Loading screenshot...'}
+ {/if} +
+ {/if} + {/if} +
+ {/if} - - {:else if uxState === 'populated'} - -
-
-
-

- {$t.validation?.run_detail || 'Run Detail'} - {#if run?.id} - #{run.id.substring(0, 8)} - {/if} -

- {#if run?.task_name} -

{run.task_name}

- {/if} -
- {#if run?.timestamp || run?.started_at || run?.created_at} - - - - - {new Date(run.timestamp || run.started_at || run.created_at).toLocaleString()} - - {/if} - - - - - {getTriggerLabel(run?.trigger)} - - - - - - {$t.validation?.duration || 'Duration'}: {formatDuration(run?.duration_ms)} - - - {dashboards.length} {$t.validation?.dashboard || 'dashboard'}{dashboards.length !== 1 ? 's' : ''} - · {totalIssues} {$t.validation?.issues?.toLowerCase() || 'issues'} - -
-
- -
- {#if failCount > 0} - - {aggregateLabel} - - {:else if warnCount > 0} - - {aggregateLabel} - - {:else} - - {aggregateLabel} - - {/if} -
-
-
+ + {#if dashboard.issues?.length} +
+

{$t.validation?.issues || 'Issues'} ({dashboard.issues.length})

+
+ {#each dashboard.issues as issue} +
+ {issue.severity || 'INFO'} + {issue.message || '-'} + {#if issue.location}{issue.location}{/if} +
+ {/each} +
+
+ {/if} - -
- {#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'} + + {#if dashboard.logs_sent_to_llm?.length} +
+ + {#if m.logsSentExpanded.has(dashboard.id || dashboard.dashboard_id)} +
{dashboard.logs_sent_to_llm.join('\n')}
+ {/if} +
+ {/if} - - -
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' : ''}" - > -
- - {getStatusDot(dashboard.status)} - - -
-

- {dashboard.title || dashboard.name || dId} -

-
- {dbIssues.length} {$t.validation?.issues?.toLowerCase() || 'issues'} - · - - {#if dbPath === 'A'}📸 {:else}📝{/if} - {getPathLabel(dbPath)} - -
-
- - - - - -
-
- - - {#if isExpanded} -
- -
-

- - - - {$t.validation?.issues || 'Issues'} -

- {#if dbIssues.length === 0} -
-

{$t.validation?.no_issues || 'No issues detected'}

-
- {:else} -
- - - - - - - - - - {#each dbIssues as issue (issue.id || `${issue.message}-${issue.location || ''}-${issue.severity || ''}`)} - - - - - - {/each} - -
{$t.validation?.severity || 'Severity'}{$t.validation?.message || 'Message'}{$t.validation?.location || 'Location'}
- - {issue.severity || issue.level || 'info'} - - -

{issue.message || '-'}

-
-

- {issue.location || issue.element || '-'} -

-
-
- {/if} -
- - -
- {#if dbPath === 'A'} - 📸 {$t.validation?.path_a || 'Path A — Screenshot'} - | {$t.validation?.screenshots || 'Screenshots'} captured via browser automation - {#if dashboard.chunk_count && dashboard.chunk_count > 1} - - {$t.validation?.chunked || 'Chunked'} ×{dashboard.chunk_count} - - {/if} - {:else} - 📝 {$t.validation?.path_b || 'Path B — Text-only'} - | {$t.validation?.issues || 'Issue'} detection via DOM extraction - {/if} -
- - - {#if dbPath === 'B'} - {@const dsHealth = dashboard.dataset_health || dashboard.datasetHealth || []} -
-

- - - - {$t.validation?.dataset_health || 'Dataset Health'} -

- {#if dsHealth.length === 0} -
-

{$t.validation?.no_issues || 'No dataset health data'}

-
- {:else} -
- - - - - - - - - - - - - {#each dsHealth as ds (ds.id || ds.name)} - - - - - - - - - {/each} - -
{$t.validation?.dataset_name || 'Name'}{$t.validation?.database || 'Database'}{$t.validation?.backend || 'Backend'}{$t.validation?.status || 'Status'}{$t.validation?.message || 'Error'}{$t.validation?.dashboard || 'Charts'}
- {ds.name || '-'} - - {ds.database || ds.db || '-'} - - {ds.backend || '-'} - - {#if ds.status === 'ok' || ds.healthy} - - - - - {$t.validation?.status_ok || '✓ Healthy'} - - {:else} - - - - - {$t.validation?.status_error || '✗ Error'} - - {/if} - -

- {ds.error || ds.message || '-'} -

-
- {ds.affected_charts || ds.charts?.length || ds.chart_count || '-'} -
-
- {/if} -
- {/if} - - - {#if dbPath === 'B' && dashboard.execute_chart_data} - {@const chartData = dashboard.chart_data || dashboard.chartData || []} -
-

- - - - {$t.validation?.chart_data || 'Chart Data Results'} -

- {#if chartData.length === 0} -
-

{$t.validation?.no_issues || 'No chart data available'}

-
- {:else} -
- - - - - - - - - - - - {#each chartData as chart (chart.id || chart.name)} - - - - - - - - {/each} - -
{$t.validation?.chart_name || 'Chart'}{$t.validation?.viz_type || 'Type'}{$t.validation?.duration || 'Duration'}{$t.validation?.row_count || 'Rows'}{$t.validation?.message || 'Error'}
- {chart.name || '-'} - - {chart.viz_type || chart.type || '-'} - - {formatDuration(chart.duration_ms || chart.duration)} - - {chart.row_count ?? chart.rows ?? '-'} - - {#if chart.error} - {chart.error} - {:else} - - {/if} -
-
- {/if} -
- {/if} - - - {#if dbPath === 'A'} - {@const screenshots = dashboard.screenshots || []} -
-

- - - - {$t.validation?.screenshots || 'Screenshots'} -

- {#if screenshots.length === 0} -
-

{$t.validation?.no_screenshots || 'No screenshots saved'}

-
- {:else} - -
- {#each screenshots as shot, idx (idx)} - {@const tabKey = `${dId}_${idx}`} - {@const tabLabel = shot.label || ''} - {@const issueTag = getTabIssueSeverity(tabLabel, dbIssues)} - - {/each} -
- - - {@const activeTab = selectedScreenshotTab[dId] || '0'} - {@const activeShot = screenshots[parseInt(activeTab)]} - {@const shotCacheKey = `${dId}_${activeTab}`} - {@const shotUrl = screenshotUrls[shotCacheKey]} - - {#if shotUrl} - -
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" - > - {`Screenshot -
-

{$t.validation?.screenshot || 'Click to open in new tab'}

- {:else if activeShot} -
- - - -

{$t.common?.loading || 'Loading...'}

-
- {/if} - {/if} -
- {/if} - - - {#if true} - {@const logsSent = dashboard.logs_sent_to_llm || dashboard.llm_prompt || dashboard.llm_logs} - {@const logsKey = `logs_${dId}`} -
- -
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" - > -

{$t.validation?.logs_sent || 'Logs sent to LLM'}

- - - -
- {#if logsSentExpanded.has(logsKey)} - {#if logsSent} -
{typeof logsSent === 'string' ? logsSent : JSON.stringify(logsSent, null, 2)}
- {:else} -
-

{$t.validation?.no_logs || 'No logs available'}

-
- {/if} - {/if} -
- {/if} - - - {#if true} - {@const execLogs = dashboard.task_logs || dashboard.executionLogs || dashboard.logs || []} - {@const taskLogsKey = `tasklogs_${dId}`} -
- -
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" - > -

{$t.validation?.task_logs || 'Task execution logs'}

- - - -
- {#if taskLogsExpanded.has(taskLogsKey)} - {#if execLogs.length > 0} -
- - - - - - - - - - - {#each execLogs as log (log.id || log.timestamp || log.time)} - - - - - - - {/each} - -
TimeLevelSourceMessage
- {log.timestamp || log.time || '-'} - - - {log.level || 'INFO'} - - - {log.source || log.logger || '-'} - -

{log.message || '-'}

-
-
- {:else} -
-

{$t.validation?.no_logs || 'No logs available'}

-
- {/if} - {/if} -
- {/if} -
- {/if} - {/each} -
- {/if} + +
+ + {#if m.taskLogsExpanded.has(`tasklogs_${dashboard.id || dashboard.dashboard_id}`) && (dashboard as Record).task_logs} +
+ + + + + + + + + + + {#each (dashboard as Record).task_logs as logEntry} + + + + + + + {/each} + +
{$t.validation?.time || 'Time'}{$t.validation?.level || 'Level'}{$t.validation?.source || 'Source'}{$t.validation?.message || 'Message'}
{new Date((logEntry as Record).timestamp as string).toLocaleString()}{(logEntry as Record).level || '-'}{(logEntry as Record).source || '-'}{(logEntry as Record).message || '-'}
+
+ {:else if m.taskLogsExpanded.has(`tasklogs_${dashboard.id || dashboard.dashboard_id}`)} +
{$t.validation?.loading_logs || 'Loading logs...'}
+ {/if} +
+
+ {/if} +
+ {/each} +
+ {/if}