refactor(frontend): enforce semantic tokens + extract 3 Screen Models
Color tokens: 3658→0 violations across 186 .svelte/.svelte.ts files. All raw Tailwind colors (bg-blue-*, text-gray-*, border-red-*, etc.) replaced with semantic tokens from tailwind.config.js in 5 perl passes. Model extraction (11→8 oversized pages): - LLMReportModel.svelte.ts: LLM report page 413→221 lines - DatasetDetailModel.svelte.ts: dataset detail page 416→218 lines - DatasetsHubModel.svelte.ts: datasets hub page 468→246 lines Tests: 14 assertions updated (color classes + model refs). Build: 1 duplicate class:text-primary fix in ValidationTaskForm. All 699/699 tests pass.
This commit is contained in:
126
frontend/src/lib/models/DatasetDetailModel.svelte.ts
Normal file
126
frontend/src/lib/models/DatasetDetailModel.svelte.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// #region DatasetDetailModel [C:3] [TYPE Model] [SEMANTICS dataset,detail,columns,dashboard,model]
|
||||
// @BRIEF Screen model for dataset detail page — loads dataset metadata, columns, SQL, and linked dashboards.
|
||||
// @INVARIANT loadDatasetDetail() must only fire when datasetId and envId are both truthy.
|
||||
// @STATE idle — No context available (missing datasetId or envId).
|
||||
// @STATE loading — API call in progress, skeleton visible.
|
||||
// @STATE loaded — Dataset details fetched, columns and dashboards rendered.
|
||||
// @STATE error — API call failed, error message displayed with retry.
|
||||
// @ACTION loadDatasetDetail() — Fetches dataset detail from API.
|
||||
// @ACTION navigateToDashboard() — Navigates to linked dashboard detail.
|
||||
// @ACTION goBack() — Navigates back to dataset list.
|
||||
// @ACTION getColumnTypeClass() — Returns Tailwind class for column type chip.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
interface DatasetColumn {
|
||||
name: string;
|
||||
type?: string;
|
||||
is_dttm?: boolean;
|
||||
is_active?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface LinkedDashboard {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
interface DatasetDetail {
|
||||
table_name: string;
|
||||
schema?: string;
|
||||
database: string;
|
||||
column_count?: number;
|
||||
linked_dashboard_count?: number;
|
||||
is_sqllab_view?: boolean;
|
||||
created_on?: string;
|
||||
changed_on?: string;
|
||||
sql?: string;
|
||||
columns?: DatasetColumn[];
|
||||
linked_dashboards?: LinkedDashboard[];
|
||||
}
|
||||
|
||||
type ScreenState = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
export class DatasetDetailModel {
|
||||
// ── Atoms ─────────────────────────────────────────────────────
|
||||
dataset: DatasetDetail | null = $state(null);
|
||||
screenState: ScreenState = $state('idle');
|
||||
error: string | null = $state(null);
|
||||
|
||||
// Set externally from page params
|
||||
datasetId: string = $state('');
|
||||
envId: string = $state('');
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
isLoading = $derived(this.screenState === 'loading');
|
||||
columnCount = $derived(this.dataset?.column_count ?? 0);
|
||||
linkedDashCount = $derived(this.dataset?.linked_dashboard_count ?? 0);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadDatasetDetail(): Promise<void> {
|
||||
if (!this.datasetId || !this.envId) {
|
||||
this.error = 'Missing context — dataset ID or environment ID not available.';
|
||||
this.screenState = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
log('DatasetDetailModel', 'REASON', 'Loading dataset detail', {
|
||||
datasetId: this.datasetId,
|
||||
envId: this.envId,
|
||||
});
|
||||
|
||||
this.screenState = 'loading';
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await api.getDatasetDetail(this.envId, this.datasetId);
|
||||
this.dataset = response as DatasetDetail;
|
||||
this.screenState = 'loaded';
|
||||
log('DatasetDetailModel', 'REFLECT', 'Dataset detail loaded', {
|
||||
table: this.dataset.table_name,
|
||||
columns: this.dataset.column_count,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load dataset detail';
|
||||
this.screenState = 'error';
|
||||
log('DatasetDetailModel', 'EXPLORE', 'Failed to load dataset', { datasetId: this.datasetId }, String(this.error));
|
||||
}
|
||||
}
|
||||
|
||||
navigateToDashboard(dashboard: LinkedDashboard): void {
|
||||
const ref = (dashboard as { slug?: string; id: string | number }).slug
|
||||
?? (dashboard as { slug?: string; id: string | number }).id;
|
||||
if (!ref) return;
|
||||
goto(ROUTES.dashboards.detail(String(ref), this.envId));
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
goto(ROUTES.datasets.list(this.envId));
|
||||
}
|
||||
|
||||
// ── Static utilities (pure, no state access) ──────────────────
|
||||
|
||||
static getColumnTypeClass(type: string | undefined): string {
|
||||
if (!type) return 'text-text-muted';
|
||||
const lt = type.toLowerCase();
|
||||
if (lt.includes('int') || lt.includes('float') || lt.includes('num')) {
|
||||
return 'text-primary bg-primary-light';
|
||||
} else if (lt.includes('date') || lt.includes('time')) {
|
||||
return 'text-success bg-success-light';
|
||||
} else if (lt.includes('str') || lt.includes('text') || lt.includes('char')) {
|
||||
return 'text-info bg-info-light';
|
||||
} else if (lt.includes('bool')) {
|
||||
return 'text-warning bg-warning-light';
|
||||
}
|
||||
return 'text-text-muted bg-surface-muted';
|
||||
}
|
||||
}
|
||||
// #endregion DatasetDetailModel
|
||||
293
frontend/src/lib/models/DatasetsHubModel.svelte.ts
Normal file
293
frontend/src/lib/models/DatasetsHubModel.svelte.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
// #region DatasetsHubModel [C:4] [TYPE Model] [SEMANTICS datasets,list,filter,pagination,bulk-action,model]
|
||||
// @BRIEF Screen model for the datasets hub — manages list state, selection, detail preview, modals, and WebSocket updates.
|
||||
// @INVARIANT Changing filter or search resets pagination to page 1.
|
||||
// @INVARIANT selectedIds is always a subset of loaded datasets — orphan IDs cleaned after each load.
|
||||
// @INVARIANT Detail panel opens only when selectedDatasetId is truthy; closing clears detailData.
|
||||
// @STATE idle — No environment selected.
|
||||
// @STATE loading — Dataset list is loading.
|
||||
// @STATE loaded — Datasets loaded, list rendered.
|
||||
// @STATE empty — No datasets match current filters.
|
||||
// @STATE error — Load failed, error shown.
|
||||
// @ACTION loadDatasets(filter?, page?) — Fetches paginated list with current search/filter.
|
||||
// @ACTION loadDetail(datasetId) — Fetches detail preview for selected dataset.
|
||||
// @ACTION setFilter(filterKey) — Sets active filter, resets pagination, reloads.
|
||||
// @ACTION setSearch(query) — Debounced search with pagination reset.
|
||||
// @ACTION setPage(n) — Navigates to page.
|
||||
// @ACTION toggleSelect(dataset, checked) — Toggles single dataset in selection set.
|
||||
// @ACTION selectAll() / clearAll() — Bulk selection operations.
|
||||
// @ACTION handleAction(dataset, action) — Opens map-columns or generate-docs modal.
|
||||
// @ACTION handleBulkMapColumns() — Runs bulk map-columns task.
|
||||
// @ACTION handleBulkGenerateDocs() — Runs bulk generate-docs task.
|
||||
// @ACTION selectDataset(id) — Opens detail preview panel.
|
||||
// @ACTION closeDetail() — Closes detail preview panel.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
// @INVARIANT DECOMPOSITION GATE: 20+ atoms. If model exceeds 400 lines or 40 methods, split into submodels.
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { debounce } from '$lib/utils/debounce.js';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
type ScreenState = 'idle' | 'loading' | 'loaded' | 'empty' | 'error';
|
||||
|
||||
interface DatasetItem {
|
||||
id: string;
|
||||
table_name: string;
|
||||
schema: string;
|
||||
database: string;
|
||||
column_count: number;
|
||||
metric_count: number;
|
||||
mappedFields: { total: number; mapped: number } | null;
|
||||
lastTask: { status: string; id: string } | null;
|
||||
}
|
||||
|
||||
interface DatasetStats {
|
||||
total?: number;
|
||||
mapped?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MapColumnsResponse {
|
||||
task_id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class DatasetsHubModel {
|
||||
// ── Environment ───────────────────────────────────────────────
|
||||
selectedEnv: string | null = $state(null);
|
||||
|
||||
// ── List state ────────────────────────────────────────────────
|
||||
datasets: DatasetItem[] = $state([]);
|
||||
stats: DatasetStats | null = $state(null);
|
||||
screenState: ScreenState = $state('idle');
|
||||
dsError: string | null = $state(null);
|
||||
page: number = $state(1);
|
||||
total: number = $state(0);
|
||||
totalPages: number = $state(1);
|
||||
pageSize: number = $state(10);
|
||||
searchQuery: string = $state('');
|
||||
activeFilter: string | null = $state(null);
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────
|
||||
selectedIds: Set<string> = $state(new SvelteSet());
|
||||
|
||||
// ── Detail preview ────────────────────────────────────────────
|
||||
selectedDatasetId: string | null = $state(null);
|
||||
detailData: Record<string, unknown> | null = $state(null);
|
||||
detailLoading: boolean = $state(false);
|
||||
detailError: string | null = $state(null);
|
||||
|
||||
// ── Modals ────────────────────────────────────────────────────
|
||||
showMapColumnsModal: boolean = $state(false);
|
||||
showGenerateDocsModal: boolean = $state(false);
|
||||
mapSourceType: string = $state('sqllab');
|
||||
mapDatabases: unknown[] = $state([]);
|
||||
mapDatabaseId: string | null = $state(null);
|
||||
mapSqlQuery: string = $state('');
|
||||
mapFileData: unknown = $state(null);
|
||||
llmProvider: string = $state('');
|
||||
llmOptions: Record<string, unknown> = $state({});
|
||||
|
||||
// ── Mobile ────────────────────────────────────────────────────
|
||||
isMobile: boolean = $state(false);
|
||||
mobileSlideOpen: boolean = $state(false);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
isLoading = $derived(this.screenState === 'loading');
|
||||
hasSelection = $derived(this.selectedIds.size > 0);
|
||||
selectionCount = $derived(this.selectedIds.size);
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────
|
||||
private _ws: unknown = null;
|
||||
private _debouncedSearch: ReturnType<typeof debounce>;
|
||||
|
||||
constructor() {
|
||||
this._debouncedSearch = debounce((q: string) => {
|
||||
this.searchQuery = q;
|
||||
this.page = 1;
|
||||
this.loadDatasets();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadDatasets(filter?: string | null, p?: number): Promise<void> {
|
||||
if (!this.selectedEnv) return;
|
||||
|
||||
log('DatasetsHubModel', 'REASON', 'Loading datasets', {
|
||||
env: this.selectedEnv, filter: filter ?? this.activeFilter, page: p ?? this.page,
|
||||
});
|
||||
|
||||
this.screenState = 'loading';
|
||||
this.dsError = null;
|
||||
try {
|
||||
const resp = await api.getDatasets(this.selectedEnv, {
|
||||
search: this.searchQuery || undefined,
|
||||
filter: (filter ?? this.activeFilter) ?? undefined,
|
||||
page: p ?? this.page,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
this.datasets = (resp.datasets as DatasetItem[]).map((d: { id: string; table_name: string; schema: string; database: string; column_count?: number; metric_count?: number; mapped_fields?: { total: number; mapped: number }; last_task?: { status: string; task_id: string } }) => ({
|
||||
id: d.id,
|
||||
table_name: d.table_name,
|
||||
schema: d.schema,
|
||||
database: d.database,
|
||||
column_count: d.mapped_fields?.total ?? 0,
|
||||
metric_count: d.metric_count ?? 0,
|
||||
mappedFields: d.mapped_fields ? { total: d.mapped_fields.total, mapped: d.mapped_fields.mapped } : null,
|
||||
lastTask: d.last_task ? { status: d.last_task.status, id: d.last_task.task_id } : null,
|
||||
}));
|
||||
|
||||
this.stats = resp.stats ?? null;
|
||||
this.total = resp.total ?? 0;
|
||||
this.totalPages = resp.total_pages ?? 1;
|
||||
this.page = resp.page ?? 1;
|
||||
|
||||
// Clean orphan selection IDs
|
||||
const keepIds = new Set<string>();
|
||||
this.datasets.forEach(d => { if (this.selectedIds.has(d.id)) keepIds.add(d.id); });
|
||||
this.selectedIds = keepIds;
|
||||
|
||||
this.screenState = this.datasets.length === 0 ? 'empty' : 'loaded';
|
||||
|
||||
log('DatasetsHubModel', 'REFLECT', 'Datasets loaded', {
|
||||
count: this.datasets.length, total: this.total, page: this.page,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this.dsError = e instanceof Error ? e.message : 'Failed to load datasets';
|
||||
this.screenState = 'error';
|
||||
log('DatasetsHubModel', 'EXPLORE', 'Failed to load datasets', { env: this.selectedEnv }, String(this.dsError));
|
||||
}
|
||||
}
|
||||
|
||||
setFilter(filterKey: string): void {
|
||||
this.activeFilter = filterKey;
|
||||
this.page = 1;
|
||||
this.loadDatasets(filterKey, 1);
|
||||
}
|
||||
|
||||
setSearch(q: string): void {
|
||||
this._debouncedSearch(q);
|
||||
}
|
||||
|
||||
setPage(p: number): void {
|
||||
this.page = p;
|
||||
this.loadDatasets(undefined, p);
|
||||
}
|
||||
|
||||
toggleSelect(dataset: DatasetItem, checked: boolean | 'all' | 'clear-all'): void {
|
||||
if (checked === 'all') {
|
||||
this.datasets.forEach(d => this.selectedIds.add(d.id));
|
||||
this.selectedIds = new Set(this.selectedIds);
|
||||
} else if (checked === 'clear-all') {
|
||||
this.selectedIds = new SvelteSet();
|
||||
} else if (checked) {
|
||||
this.selectedIds.add(dataset.id);
|
||||
this.selectedIds = new Set(this.selectedIds);
|
||||
} else {
|
||||
this.selectedIds.delete(dataset.id);
|
||||
this.selectedIds = new Set(this.selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
selectAll(): void { this.toggleSelect({ id: '' } as DatasetItem, 'all'); }
|
||||
clearAll(): void { this.toggleSelect({ id: '' } as DatasetItem, 'clear-all'); }
|
||||
|
||||
async loadDetail(datasetId: string): Promise<void> {
|
||||
if (!datasetId) return;
|
||||
this.detailLoading = true;
|
||||
this.detailError = null;
|
||||
try {
|
||||
const d = await api.getDatasetDetail(this.selectedEnv!, datasetId);
|
||||
this.detailData = { ...d, schema: (d as { schema_name?: string; schema?: string }).schema_name ?? (d as { schema_name?: string; schema?: string }).schema };
|
||||
} catch (e: unknown) {
|
||||
this.detailError = e instanceof Error ? e.message : 'Failed to load detail';
|
||||
} finally {
|
||||
this.detailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
selectDataset(id: string): void {
|
||||
this.selectedDatasetId = id;
|
||||
this.detailData = null;
|
||||
this.detailError = null;
|
||||
if (this.isMobile) this.mobileSlideOpen = true;
|
||||
this.loadDetail(id);
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.selectedDatasetId = null;
|
||||
this.detailData = null;
|
||||
this.detailError = null;
|
||||
this.mobileSlideOpen = false;
|
||||
}
|
||||
|
||||
handleAction(dataset: DatasetItem, action: string): void {
|
||||
if (action === 'map_columns') {
|
||||
this.selectedIds = new SvelteSet([dataset.id]);
|
||||
this.showMapColumnsModal = true;
|
||||
this.mapSourceType = 'sqllab';
|
||||
this.mapDatabaseId = null;
|
||||
this.mapSqlQuery = '';
|
||||
this.mapFileData = null;
|
||||
if (this.selectedEnv) {
|
||||
api.getEnvironmentDatabases(this.selectedEnv).then((dbs: unknown) => { this.mapDatabases = (dbs as unknown[]) ?? []; }).catch(() => {});
|
||||
}
|
||||
} else if (action === 'generate_docs') {
|
||||
this.selectedIds = new SvelteSet([dataset.id]);
|
||||
this.showGenerateDocsModal = true;
|
||||
this.llmProvider = '';
|
||||
this.llmOptions = {};
|
||||
}
|
||||
}
|
||||
|
||||
async handleBulkMapColumns(): Promise<void> {
|
||||
if (this.selectedIds.size === 0) return;
|
||||
if (this.mapSourceType === 'sqllab' && !this.mapDatabaseId) return;
|
||||
if (this.mapSourceType === 'xlsx' && (!this.mapFileData || (this.mapFileData as unknown[]).length === 0)) return;
|
||||
|
||||
try {
|
||||
let fileData = null;
|
||||
if (this.mapSourceType === 'xlsx' && (this.mapFileData as unknown[])?.length)
|
||||
fileData = (this.mapFileData as unknown[])[0] as { name: string };
|
||||
|
||||
const resp = await api.postApi('/datasets/map-columns', {
|
||||
env_id: this.selectedEnv,
|
||||
dataset_ids: [...this.selectedIds],
|
||||
source_type: this.mapSourceType,
|
||||
database_id: this.mapDatabaseId,
|
||||
sql_query: this.mapSqlQuery,
|
||||
file_data: fileData?.name,
|
||||
}) as MapColumnsResponse;
|
||||
|
||||
log('DatasetsHubModel', 'REFLECT', 'Bulk map columns started', { taskId: resp.task_id });
|
||||
this.showMapColumnsModal = false;
|
||||
this.selectedIds = new SvelteSet();
|
||||
} catch (e: unknown) {
|
||||
log('DatasetsHubModel', 'EXPLORE', 'Bulk map columns failed', {}, String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async handleBulkGenerateDocs(): Promise<void> {
|
||||
if (this.selectedIds.size === 0) return;
|
||||
try {
|
||||
const resp = await api.postApi('/datasets/generate-docs', {
|
||||
env_id: this.selectedEnv,
|
||||
dataset_ids: [...this.selectedIds],
|
||||
llm_provider: this.llmProvider,
|
||||
llm_options: this.llmOptions,
|
||||
}) as MapColumnsResponse;
|
||||
|
||||
log('DatasetsHubModel', 'REFLECT', 'Bulk docs generation started', { taskId: resp.task_id });
|
||||
this.showGenerateDocsModal = false;
|
||||
this.selectedIds = new SvelteSet();
|
||||
} catch (e: unknown) {
|
||||
log('DatasetsHubModel', 'EXPLORE', 'Bulk docs failed', {}, String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion DatasetsHubModel
|
||||
@@ -151,42 +151,42 @@ export class GitStatusModel {
|
||||
const _t = tt();
|
||||
if (syncState === 'SYNCED') {
|
||||
return {
|
||||
dotClass: 'bg-emerald-500',
|
||||
pillClass: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
dotClass: 'bg-success',
|
||||
pillClass: 'bg-success-light text-success border-success',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.synced || 'Synced',
|
||||
};
|
||||
}
|
||||
if (syncState === 'CHANGES') {
|
||||
return {
|
||||
dotClass: 'bg-amber-500',
|
||||
pillClass: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
dotClass: 'bg-warning',
|
||||
pillClass: 'bg-warning-light text-warning border-warning',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.changes || 'Modified',
|
||||
};
|
||||
}
|
||||
if (syncState === 'AHEAD_REMOTE') {
|
||||
return {
|
||||
dotClass: 'bg-blue-500',
|
||||
pillClass: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
dotClass: 'bg-primary-ring',
|
||||
pillClass: 'bg-primary-light text-primary border-primary-ring',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.ahead_remote || 'Ahead',
|
||||
};
|
||||
}
|
||||
if (syncState === 'BEHIND_REMOTE') {
|
||||
return {
|
||||
dotClass: 'bg-indigo-500',
|
||||
pillClass: 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
dotClass: 'bg-primary',
|
||||
pillClass: 'bg-primary-light text-primary border-primary-ring',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.behind_remote || 'Behind',
|
||||
};
|
||||
}
|
||||
if (syncState === 'DIVERGED') {
|
||||
return {
|
||||
dotClass: 'bg-rose-500',
|
||||
pillClass: 'bg-rose-100 text-rose-800 border-rose-200',
|
||||
dotClass: 'bg-destructive-light',
|
||||
pillClass: 'bg-destructive-light text-destructive border-destructive-ring',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.diverged || 'Diverged',
|
||||
};
|
||||
}
|
||||
return {
|
||||
dotClass: 'bg-slate-400',
|
||||
pillClass: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
dotClass: 'bg-border-strong',
|
||||
pillClass: 'bg-surface-muted text-text border-border',
|
||||
label: (_t?.git as Record<string, Record<string, string>>)?.repo_status?.no_repo || 'Uninitialized',
|
||||
};
|
||||
});
|
||||
|
||||
201
frontend/src/lib/models/LLMReportModel.svelte.ts
Normal file
201
frontend/src/lib/models/LLMReportModel.svelte.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// #region LLMReportModel [C:3] [TYPE Model] [SEMANTICS llm,report,validation,screenshot,model]
|
||||
// @BRIEF Screen model for the LLM validation report page — loads task details, logs, and thumbnail screenshots.
|
||||
// @INVARIANT taskId is derived from route params; loadReport() must only fire when taskId is truthy.
|
||||
// @INVARIANT screenshot loading is token-gated to prevent stale blob URLs from overwriting valid ones.
|
||||
// @STATE idle — No taskId available, nothing loaded.
|
||||
// @STATE loading — API calls in progress, skeleton visible.
|
||||
// @STATE loaded — Task data and logs fetched, report rendered.
|
||||
// @STATE error — API call failed, error message displayed.
|
||||
// @ACTION loadReport() — Fetches task + logs from API.
|
||||
// @ACTION loadScreenshotBlobs() — Fetches blob URLs for screenshot paths.
|
||||
// @ACTION openScreenshot(path) — Opens screenshot blob URL in new tab.
|
||||
// @ACTION cleanupBlobs() — Revokes all screenshot blob URLs.
|
||||
// @ACTION refresh() — Re-runs loadReport().
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import type { TaskLogEntry } from '$lib/api.js';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
type ScreenState = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
interface TimingInfo {
|
||||
validation_duration_ms?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CheckIssue {
|
||||
severity?: string;
|
||||
message?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
interface TaskResult {
|
||||
status?: string;
|
||||
summary?: string;
|
||||
issues?: CheckIssue[];
|
||||
screenshot_paths?: string[];
|
||||
screenshot_path?: string;
|
||||
logs_sent_to_llm?: string[];
|
||||
timings?: TimingInfo;
|
||||
}
|
||||
|
||||
interface TaskData {
|
||||
id: string;
|
||||
status?: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
result?: TaskResult;
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
label: string;
|
||||
level: 'fail' | 'warn' | 'pass' | 'unknown';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export class LLMReportModel {
|
||||
// ── Atoms ─────────────────────────────────────────────────────
|
||||
task: TaskData | null = $state(null);
|
||||
logs: TaskLogEntry[] = $state([]);
|
||||
screenState: ScreenState = $state('idle');
|
||||
error: string | null = $state(null);
|
||||
screenshotBlobUrls: Record<string, string> = $state({});
|
||||
screenshotLoadErrors: Record<string, string> = $state({});
|
||||
private _screenshotLoadToken: number = $state(0);
|
||||
|
||||
// Externally set
|
||||
taskId: string = $state('');
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
result = $derived<TaskResult>(this.task?.result || {});
|
||||
checkResult = $derived<CheckResult>(this._getDashboardCheckResult(this.result));
|
||||
timings = $derived<TimingInfo>(this.result?.timings || {});
|
||||
screenshotPaths = $derived<string[]>(
|
||||
Array.isArray(this.result?.screenshot_paths) && this.result.screenshot_paths.length > 0
|
||||
? this.result.screenshot_paths
|
||||
: this.result?.screenshot_path
|
||||
? [this.result.screenshot_path]
|
||||
: [],
|
||||
);
|
||||
sentLogs = $derived<string[]>(
|
||||
Array.isArray(this.result?.logs_sent_to_llm) ? this.result.logs_sent_to_llm : [],
|
||||
);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadReport(): Promise<void> {
|
||||
if (!this.taskId) return;
|
||||
|
||||
log('LLMReportModel', 'REASON', 'Loading LLM report', { taskId: this.taskId });
|
||||
|
||||
this.screenState = 'loading';
|
||||
this.error = null;
|
||||
try {
|
||||
const [taskPayload, logsPayload] = await Promise.all([
|
||||
api.getTask(this.taskId),
|
||||
api.getTaskLogs(this.taskId, { limit: 1000 }),
|
||||
]);
|
||||
this.task = taskPayload as TaskData;
|
||||
this.logs = Array.isArray(logsPayload) ? logsPayload : [];
|
||||
this.screenState = 'loaded';
|
||||
log('LLMReportModel', 'REFLECT', 'LLM report loaded', {
|
||||
taskId: this.taskId,
|
||||
status: this.task?.status,
|
||||
logCount: this.logs.length,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to load LLM report';
|
||||
this.screenState = 'error';
|
||||
log('LLMReportModel', 'EXPLORE', 'Failed to load LLM report', { taskId: this.taskId }, String(this.error));
|
||||
}
|
||||
}
|
||||
|
||||
async loadScreenshotBlobs(paths: string[]): Promise<void> {
|
||||
const currentToken = ++this._screenshotLoadToken;
|
||||
this._cleanupBlobUrls();
|
||||
this.screenshotLoadErrors = {};
|
||||
|
||||
if (!Array.isArray(paths) || paths.length === 0) return;
|
||||
|
||||
const nextUrls: Record<string, string> = {};
|
||||
const nextErrors: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
try {
|
||||
const blob = await api.getStorageFileBlob(path);
|
||||
nextUrls[path] = URL.createObjectURL(blob);
|
||||
} catch (err: unknown) {
|
||||
nextErrors[path] = err instanceof Error ? err.message : 'Failed to load screenshot';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (currentToken !== this._screenshotLoadToken) {
|
||||
for (const url of Object.values(nextUrls)) URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
this.screenshotBlobUrls = nextUrls;
|
||||
this.screenshotLoadErrors = nextErrors;
|
||||
}
|
||||
|
||||
openScreenshot(path: string): void {
|
||||
const url = this.screenshotBlobUrls[path];
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
cleanupBlobs(): void {
|
||||
this._cleanupBlobUrls();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadReport();
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────
|
||||
|
||||
private _cleanupBlobUrls(): void {
|
||||
for (const url of Object.values(this.screenshotBlobUrls)) {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}
|
||||
this.screenshotBlobUrls = {};
|
||||
}
|
||||
|
||||
private _getDashboardCheckResult(resultPayload: TaskResult): CheckResult {
|
||||
const rawStatus = String(resultPayload?.status || '').toUpperCase();
|
||||
if (rawStatus === 'FAIL') return { label: 'FAIL', level: 'fail', icon: '!' };
|
||||
if (rawStatus === 'WARN') return { label: 'WARN', level: 'warn', icon: '!' };
|
||||
if (rawStatus === 'PASS') return { label: 'PASS', level: 'pass', icon: 'OK' };
|
||||
return { label: 'UNKNOWN', level: 'unknown', icon: '?' };
|
||||
}
|
||||
|
||||
// ── Static utils (pure, no state access) ──────────────────────
|
||||
|
||||
static formatDate(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value as string);
|
||||
if (Number.isNaN(parsed.getTime())) return '-';
|
||||
return `${parsed.toLocaleDateString()} ${parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`;
|
||||
}
|
||||
|
||||
static formatMs(value: unknown): string {
|
||||
const ms = Number(value);
|
||||
if (!Number.isFinite(ms) || ms < 0) return '-';
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
static getCheckResultClasses(level: CheckResult['level']): string {
|
||||
if (level === 'fail') return 'bg-destructive-light text-destructive border-destructive-ring';
|
||||
if (level === 'warn') return 'bg-warning-light text-warning border-warning';
|
||||
if (level === 'pass') return 'bg-success-light text-success border-success';
|
||||
return 'bg-surface-muted text-text border-border';
|
||||
}
|
||||
}
|
||||
// #endregion LLMReportModel
|
||||
Reference in New Issue
Block a user