- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models - Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs) - Add 13 @ingroup tags for DSA/HCA attention grouping - Repair 29 stale graph edges via index rebuild - Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance - Git integration: merge routes, branch lifecycle, remote providers, UX components - 0 broken anchor pairs, index rebuilt with 0 parse warnings
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
// #region Datasets.HubModel [C:4] [TYPE Model] [SEMANTICS datasets,list,filter,pagination,bulk-action,model]
|
|
// @ingroup Datasets
|
|
// @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 -> [ApiModule]
|
|
// @RELATION CALLS -> [CotLogger]
|
|
// @INVARIANT DECOMPOSITION GATE: 20+ atoms. If model exceeds 400 lines or 40 methods, split into submodels.
|
|
// @RATIONALE Datasets.HubModel centralizes all dataset-hub state (list, selection, detail preview, modals, mobile) into one testable class. Model-first ensures cross-widget invariants (filter → pagination reset, selection ↔ bulk action visibility, mobile slide-over ↔ detail) are verifiable without DOM rendering. The SvelteSet-backed selection set provides O(1) add/delete/has without Svelte 5 reactivity surprises.
|
|
// @REJECTED Component-first state management rejected — scattering filter/selection/modal state across event handlers in +page.svelte creates invisible cross-widget coupling that LLM agents cannot reliably maintain. Multiple smaller models (DatasetsListModel, SelectionModel, MappingModalModel) rejected — the decomposition overhead (cross-model synchronization, extra connections) exceeds benefit at current complexity. Re-evaluate at 40+ methods via @INVARIANT DECOMPOSITION GATE.
|
|
|
|
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 SvelteSet<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 SvelteSet(this.selectedIds);
|
|
} else if (checked === 'clear-all') {
|
|
this.selectedIds = new SvelteSet();
|
|
} else if (checked) {
|
|
this.selectedIds.add(dataset.id);
|
|
this.selectedIds = new SvelteSet(this.selectedIds);
|
|
} else {
|
|
this.selectedIds.delete(dataset.id);
|
|
this.selectedIds = new SvelteSet(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 Datasets.HubModel
|