From bb94d00dd2085e3628e481792099327aa3fa583a Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 2 Jun 2026 18:16:19 +0300 Subject: [PATCH] refactor(frontend): extract DashboardDetailModel for dashboard detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard detail: 584→208 lines (-64%). DashboardDetailModel.svelte.ts: 14 atoms, 10 async actions, Git status delegation via GitStatusModel. Static utilities (formatDate, getValidationStatus, etc.). 8→7 oversized pages remaining. --- .../lib/models/DashboardDetailModel.svelte.ts | 311 +++++++++ .../src/routes/dashboards/[id]/+page.svelte | 611 ++++-------------- 2 files changed, 429 insertions(+), 493 deletions(-) create mode 100644 frontend/src/lib/models/DashboardDetailModel.svelte.ts diff --git a/frontend/src/lib/models/DashboardDetailModel.svelte.ts b/frontend/src/lib/models/DashboardDetailModel.svelte.ts new file mode 100644 index 00000000..a849e524 --- /dev/null +++ b/frontend/src/lib/models/DashboardDetailModel.svelte.ts @@ -0,0 +1,311 @@ +// #region DashboardDetailModel [C:4] [TYPE Model] [SEMANTICS dashboard,detail,git,task-history,thumbnail,validation,model] +// @BRIEF Screen model for dashboard detail page — loads metadata, thumbnail, task history, validation history, and manages Git status. +// @INVARIANT loadDashboardPage() must only fire when dashboardRef and envId are both truthy. +// @INVARIANT Thumbnail blob URLs are released on reassignment or model destruction. +// @STATE idle — No context available. +// @STATE loading — Dashboard detail is loading. +// @STATE loaded — Dashboard metadata and linked assets rendered. +// @STATE error — Load failed, error message displayed with retry. +// @ACTION loadDashboardPage() — Loads all dashboard data in parallel. +// @ACTION loadDashboardDetail() — Fetches dashboard metadata from API. +// @ACTION loadTaskHistory() — Fetches recent task history. +// @ACTION loadThumbnail(force?) — Fetches dashboard thumbnail as blob URL. +// @ACTION loadValidationHistory() — Fetches LLM validation run history. +// @ACTION runBackupTask() — Starts backup task for this dashboard. +// @ACTION goBack() — Navigates back to dashboard list. +// @ACTION openDataset(id) — Navigates to dataset detail. +// @RELATION DEPENDS_ON -> [api] +// @RELATION DEPENDS_ON -> [GitStatusModel] +// @RELATION CALLS -> [log] + +import { goto } from '$app/navigation'; +import { ROUTES } from '$lib/routes'; +import { api } from '$lib/api.js'; +import { fetchApi } from '$lib/api'; +import { log } from '$lib/cot-logger'; +import { GitStatusModel } from '$lib/models/GitStatusModel.svelte.ts'; +import { addToast } from '$lib/toasts.svelte.js'; +import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js'; +import { t } from '$lib/i18n/index.svelte.js'; + +// ── Types ─────────────────────────────────────────────────────── + +type ScreenState = 'idle' | 'loading' | 'loaded' | 'error'; +type ActiveTab = 'resources' | 'git-history' | 'validation-history'; + +interface DashboardMetadata { + id: number; + title: string; + slug?: string; + chart_count?: number; + dataset_count?: number; + [key: string]: unknown; +} + +interface TaskHistoryItem { + id: string; + plugin_id?: string; + status?: string; + validation_status?: string; + timestamp?: string; + started_at?: string; + finished_at?: string; + summary?: string; + [key: string]: unknown; +} + +interface ValidationRunItem { + id: string; + plugin_id?: string; + status?: string; + validation_status?: string; + timestamp?: string; + run_timestamp?: string; + started_at?: string; + [key: string]: unknown; +} + +interface ValidationStatus { + label: string; + level: 'fail' | 'warn' | 'pass' | 'unknown' | 'na'; + icon: string; +} + +export class DashboardDetailModel { + // ── Context ─────────────────────────────────────────────────── + dashboardRef: string = $state(''); + envId: string = $state(''); + + // ── Dashboard metadata ──────────────────────────────────────── + dashboard: DashboardMetadata | null = $state(null); + screenState: ScreenState = $state('idle'); + error: string | null = $state(null); + + // ── Task history ───────────────────────────────────────────── + taskHistory: TaskHistoryItem[] = $state([]); + isTaskHistoryLoading: boolean = $state(false); + taskHistoryError: string | null = $state(null); + + // ── Backup ──────────────────────────────────────────────────── + isStartingBackup: boolean = $state(false); + + // ── Thumbnail ──────────────────────────────────────────────── + thumbnailUrl: string = $state(''); + isThumbnailLoading: boolean = $state(false); + thumbnailError: string | null = $state(null); + + // ── Validation history ─────────────────────────────────────── + validationHistory: ValidationRunItem[] = $state([]); + isValidationHistoryLoading: boolean = $state(false); + validationHistoryError: string | null = $state(null); + + // ── Tabs / UI ───────────────────────────────────────────────── + activeTab: ActiveTab = $state('resources'); + + // ── Git ─────────────────────────────────────────────────────── + gitModel: GitStatusModel = new GitStatusModel(); + showGitManager: boolean = $state(false); + private _wasGitManagerOpen: boolean = $state(false); + + // ── Derived ─────────────────────────────────────────────────── + isLoading = $derived(this.screenState === 'loading'); + gitDashboardRef = $derived(this.dashboard?.slug || this.dashboardRef || ''); + resolvedDashboardId = $derived(this.dashboard?.id ?? (/^\d+$/.test(String(this.dashboardRef || '')) ? Number(this.dashboardRef) : null)); + + // ── Actions ─────────────────────────────────────────────────── + + async loadDashboardPage(): Promise { + await this.loadDashboardDetail(); + this.gitModel.dashboardId = this.gitDashboardRef; + this.gitModel.envId = this.envId || null; + const effectiveRef = this.dashboard?.id ?? this.dashboardRef; + await Promise.all([ + this.loadTaskHistory(effectiveRef), + this.loadThumbnail(false, effectiveRef), + this.loadValidationHistory(effectiveRef), + this.gitModel.loadStatus(), + ]); + } + + async loadDashboardDetail(): Promise { + if (!this.dashboardRef || !this.envId) { + this.error = $t.dashboard?.missing_context; + this.screenState = 'error'; + return; + } + + log('DashboardDetailModel', 'REASON', 'Loading dashboard detail', { ref: this.dashboardRef, envId: this.envId }); + this.screenState = 'loading'; + this.error = null; + try { + this.dashboard = await api.getDashboardDetail(this.envId, this.dashboardRef) as DashboardMetadata; + this.screenState = 'loaded'; + log('DashboardDetailModel', 'REFLECT', 'Dashboard detail loaded', { id: this.dashboard?.id, title: this.dashboard?.title }); + } catch (err: unknown) { + this.error = err instanceof Error ? err.message : String($t.dashboard?.load_detail_failed); + this.screenState = 'error'; + log('DashboardDetailModel', 'EXPLORE', 'Failed to load dashboard', { ref: this.dashboardRef }, String(this.error)); + } + } + + async loadTaskHistory(targetRef?: string | number): Promise { + const ref = targetRef ?? this.dashboardRef; + if (!ref || !this.envId) return; + this.isTaskHistoryLoading = true; + this.taskHistoryError = null; + try { + const response = await api.getDashboardTaskHistory(this.envId, String(ref), { limit: 30 }) as { items?: TaskHistoryItem[] }; + this.taskHistory = response?.items || []; + } catch (err: unknown) { + this.taskHistoryError = err instanceof Error ? err.message : 'Failed to load task history'; + this.taskHistory = []; + } finally { + this.isTaskHistoryLoading = false; + } + } + + async loadThumbnail(force = false, targetRef?: string | number): Promise { + const ref = targetRef ?? this.dashboardRef; + if (!ref || !this.envId) return; + this.isThumbnailLoading = true; + this.thumbnailError = null; + try { + const blob = await api.getDashboardThumbnail(this.envId, String(ref), { force }) as Blob; + this._releaseThumbnail(); + this.thumbnailUrl = URL.createObjectURL(blob); + } catch (err: unknown) { + if ((err as { status?: number })?.status === 202) { + this.thumbnailError = $t.dashboard?.thumbnail_generating || 'Thumbnail is being generated'; + } else { + this.thumbnailError = err instanceof Error ? err.message : String($t.dashboard?.thumbnail_failed); + } + } finally { + this.isThumbnailLoading = false; + } + } + + async loadValidationHistory(targetRef?: string | number): Promise { + const ref = targetRef ?? this.dashboardRef; + if (!ref || !this.envId) return; + this.isValidationHistoryLoading = true; + this.validationHistoryError = null; + try { + const qs = new URLSearchParams(); + qs.append('dashboard_id', String(ref)); + qs.append('environment_id', this.envId); + qs.append('page_size', '10'); + const response = await fetchApi(`/validation-tasks/runs/all?${qs.toString()}`); + this.validationHistory = (response as { items?: ValidationRunItem[]; results?: ValidationRunItem[] })?.items + || (response as { items?: ValidationRunItem[]; results?: ValidationRunItem[] })?.results + || []; + } catch (err: unknown) { + this.validationHistoryError = err instanceof Error ? err.message : 'Failed to load validation history'; + this.validationHistory = []; + } finally { + this.isValidationHistoryLoading = false; + } + } + + async runBackupTask(): Promise { + if (this.isStartingBackup || !this.envId || !this.resolvedDashboardId) return; + this.isStartingBackup = true; + try { + const response = await api.postApi('/dashboards/backup', { + env_id: this.envId, + dashboard_ids: [this.resolvedDashboardId], + }) as { task_id?: string }; + const taskId = response?.task_id; + if (taskId) { + openDrawerForTaskIfPreferred(taskId); + addToast($t.dashboard?.backup_started || 'Backup task started', 'success'); + } + await this.loadTaskHistory(); + } catch (err: unknown) { + addToast(err instanceof Error ? err.message : $t.dashboard?.backup_task_failed || 'Failed to start backup', 'error'); + } finally { + this.isStartingBackup = false; + } + } + + async handleSyncAndOpenCommit(): Promise { + const success = await this.gitModel.syncRepository(); + if (success) this.showGitManager = true; + } + + trackGitManagerClose(): void { + if (this.showGitManager) { + this._wasGitManagerOpen = true; + } else if (this._wasGitManagerOpen) { + this._wasGitManagerOpen = false; + void this.gitModel.loadStatus(); + } + } + + cleanup(): void { + this._releaseThumbnail(); + } + + // ── Navigation ──────────────────────────────────────────────── + + goBack(): void { + goto(ROUTES.dashboards.list(this.envId)); + } + + openDataset(datasetId: string | number): void { + goto(ROUTES.datasets.detail(String(datasetId), this.envId)); + } + + // ── Private ─────────────────────────────────────────────────── + + private _releaseThumbnail(): void { + if (this.thumbnailUrl) { + URL.revokeObjectURL(this.thumbnailUrl); + this.thumbnailUrl = ''; + } + } + + // ── Static utilities ────────────────────────────────────────── + + static toTaskTypeLabel(pluginId: string | undefined): string { + if (pluginId === 'superset-backup') return $t.dashboard?.backup || 'Backup'; + if (pluginId === 'llm_dashboard_validation') return $t.dashboard?.llm_check || 'LLM Check'; + return pluginId || '-'; + } + + static getTaskStatusClasses(status: string | undefined): string { + const n = (status || '').toLowerCase(); + if (n === 'running' || n === 'pending') return 'bg-primary-light text-primary'; + if (n === 'success') return 'bg-success-light text-success'; + if (n === 'failed' || n === 'error') return 'bg-destructive-light text-destructive'; + if (n === 'awaiting_input' || n === 'waiting_input') return 'bg-warning-light text-warning'; + return 'bg-surface-muted text-text'; + } + + static getValidationStatus(task: { plugin_id?: string; validation_status?: string }): ValidationStatus { + if (task?.plugin_id !== 'llm_dashboard_validation') { + return { label: '-', level: 'na', icon: '' }; + } + const raw = String(task?.validation_status || '').toUpperCase(); + if (raw === 'FAIL') return { label: 'FAIL', level: 'fail', icon: '!' }; + if (raw === 'WARN') return { label: 'WARN', level: 'warn', icon: '!' }; + if (raw === 'PASS') return { label: 'PASS', level: 'pass', icon: 'OK' }; + return { label: 'UNKNOWN', level: 'unknown', icon: '?' }; + } + + static getValidationStatusClasses(level: ValidationStatus['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'; + if (level === 'unknown') return 'bg-surface-muted text-text border-border'; + if (level === 'na') return 'bg-surface-page text-text-subtle border-border'; + return 'bg-surface-page text-text-subtle border-border'; + } + + 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' })}`; + } +} +// #endregion DashboardDetailModel diff --git a/frontend/src/routes/dashboards/[id]/+page.svelte b/frontend/src/routes/dashboards/[id]/+page.svelte index 495e18de..775f4c12 100644 --- a/frontend/src/routes/dashboards/[id]/+page.svelte +++ b/frontend/src/routes/dashboards/[id]/+page.svelte @@ -1,516 +1,169 @@ - + + - - - - - - - - - + + +
gitModel.handleBranchChange(e)} - {runBackupTask} - {loadDashboardPage} + dashboard={m.dashboard} + resolvedDashboardId={m.resolvedDashboardId} + dashboardRef={m.dashboardRef} + envId={m.envId} + gitDashboardRef={m.gitDashboardRef} + hasGitRepo={m.gitModel.hasGitRepo} + bind:currentBranch={m.gitModel.currentBranch} + gitMeta={m.gitModel.meta} + gitStatus={m.gitModel.gitStatus} + isStartingBackup={m.isStartingBackup} + bind:showGitManager={m.showGitManager} + goBack={() => m.goBack()} + handleBranchChange={(e) => m.gitModel.handleBranchChange(e)} + runBackupTask={() => m.runBackupTask()} + loadDashboardPage={() => m.loadDashboardPage()} /> - {#if error} -
- {error} - + {#if m.error} +
+ {m.error} +
{/if} - {#if isLoading} + {#if m.isLoading}
{#each Array(3) as _} -
+
{/each}
-
- {:else if dashboard} +
+ {:else if m.dashboard}
+
-

- {$t.dashboard?.api_thumbnail || "Dashboard thumbnail"} -

- +

{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}

+
- {#if isThumbnailLoading} + {#if m.isThumbnailLoading}
- {:else if thumbnailUrl} - Dashboard thumbnail + {:else if m.thumbnailUrl} + Dashboard thumbnail {:else} -
- {thumbnailError || - $t.dashboard?.thumbnail_unavailable || - "Thumbnail is unavailable"} -
+
{m.thumbnailError || $t.dashboard?.thumbnail_unavailable || "Thumbnail is unavailable"}
{/if}
+ gitModel.loadDiffPreview()} - isGitDiffLoading={gitModel.diffLoading} - runGitPull={() => gitModel.pullRepository()} - isPullingGit={gitModel.pulling} - runGitPush={() => gitModel.pushRepository()} - isPushingGit={gitModel.pushing} - gitDiffPreview={gitModel.diffPreview} - loadGitStatus={() => gitModel.loadStatus()} - bind:showGitManager + isGitStatusLoading={m.gitModel.statusLoading} + gitStatusError={m.gitModel.statusError} + hasGitRepo={m.gitModel.hasGitRepo} + gitSyncState={m.gitModel.syncState} + gitStatus={m.gitModel.gitStatus} + changedChartsCount={m.gitModel.changedChartsCount} + changedDatasetsCount={m.gitModel.changedDatasetsCount} + allChangedFiles={m.gitModel.allChangedFilesList} + runGitSyncAndOpenCommit={() => m.handleSyncAndOpenCommit()} + isSyncingGit={m.gitModel.syncing} + hasChangesToCommit={m.gitModel.hasChangesToCommit} + loadGitDiffPreview={() => m.gitModel.loadDiffPreview()} + isGitDiffLoading={m.gitModel.diffLoading} + runGitPull={() => m.gitModel.pullRepository()} + isPullingGit={m.gitModel.pulling} + runGitPush={() => m.gitModel.pushRepository()} + isPushingGit={m.gitModel.pushing} + gitDiffPreview={m.gitModel.diffPreview} + loadGitStatus={() => m.gitModel.loadStatus()} + bind:showGitManager={m.showGitManager} />
+
-

- {$t.dashboard?.last_modified} -

-

- {formatDate(dashboard.last_modified)} -

+

{$t.dashboard?.last_modified}

+

{formatDate(m.dashboard.last_modified)}

-

- {$t.dashboard?.charts} -

-

- {dashboard.chart_count || 0} -

+

{$t.dashboard?.charts}

+

{m.dashboard.chart_count || 0}

-

- {$t.nav?.datasets} -

-

- {dashboard.dataset_count || 0} -

+

{$t.nav?.datasets}

+

{m.dashboard.dataset_count || 0}

- +
-

- {$t.dashboard?.validation_history || "Validation History"} -

- +

{$t.dashboard?.validation_history || "Validation History"}

+
- {#if isValidationHistoryLoading} -
- {#each Array(3) as _} -
- {/each} -
- {:else if validationHistoryError} -
- {validationHistoryError} -
- {:else if validationHistory.length === 0} -
- {$t.dashboard?.no_validation_runs || "No validation runs yet"} -
+ {#if m.isValidationHistoryLoading} +
{#each Array(3) as _}
{/each}
+ {:else if m.validationHistoryError} +
{m.validationHistoryError}
+ {:else if m.validationHistory.length === 0} +
{$t.dashboard?.no_validation_runs || "No validation runs yet"}
{:else}
- - - - - - - - + + + + + + - {#each validationHistory as run} + {#each m.validationHistory as run} {@const runStatus = String(run.status || "").toUpperCase()} - - + + @@ -518,52 +171,30 @@
{$t.common?.date}{$t.tasks?.name || "Task Name"}{$t.common?.status}{$t.common?.actions}
{$t.common?.date}{$t.tasks?.name || "Task Name"}{$t.common?.status}{$t.common?.actions}
{formatDate(run.timestamp || run.created_at || run.started_at)}{run.summary ? run.summary.slice(0, 80) + (run.summary.length > 80 ? '…' : '') : '-'}{formatDate(run.timestamp || (run as Record).created_at || run.started_at)}{run.summary ? run.summary.slice(0, 80) + (run.summary.length > 80 ? '\u2026' : '') : '-'} - - {runStatus || "UNKNOWN"} - + {runStatus || 'UNKNOWN'} - {#if run.policy_id || run.policyId} - - {$t.common?.view_report || "Report"} - + {#if run.policy_id || (run as Record).policyId} + ).policyId as string, run.id || (run as Record).run_id as string)} class="inline-flex items-center gap-1 rounded-md border border-primary-ring bg-primary-light px-2 py-1 text-xs text-primary hover:bg-primary-light">{$t.common?.view_report || "Report"} {:else if run.id} - + {/if}
- {/if}
+
- - - + +
- {#if activeTab === "resources"} - - {:else if activeTab === "git-history"} - {#if gitModel.hasGitRepo && gitDashboardRef} - + {#if m.activeTab === 'resources'} + m.openDataset(id)} {formatDate} /> + {:else if m.activeTab === 'git-history'} + {#if m.gitModel.hasGitRepo && m.gitDashboardRef} + {:else} -
- {$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."} -
+
{$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."}
{/if} {/if}
@@ -571,13 +202,7 @@ {/if}
-{#if showGitManager && gitDashboardRef} - +{#if m.showGitManager && m.gitDashboardRef} + {/if} -