refactor(frontend): extract DashboardDetailModel for dashboard detail page

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.
This commit is contained in:
2026-06-02 18:16:19 +03:00
parent e171155dba
commit bb94d00dd2
2 changed files with 429 additions and 493 deletions

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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

View File

@@ -1,516 +1,169 @@
<!-- #region DashboardDetail [C:4] [TYPE Page] [SEMANTICS sveltekit, dashboard, detail, git, llm] -->
<!-- @BRIEF Dashboard detail view showing metadata, thumbnail, linked charts/datasets, Git status, task history, and LLM validation. -->
<!-- @BRIEF Dashboard detail view — renders DashboardDetailModel state, delegates all logic to the model. -->
<!-- @LAYER Page -->
<!-- @RELATION BINDS_TO -> [DashboardDetailModel] -->
<!-- @RELATION DEPENDS_ON -> [DashboardHeader] -->
<!-- @RELATION DEPENDS_ON -> [DashboardGitManager] -->
<!-- @RELATION DEPENDS_ON -> [DashboardLinkedResources] -->
<!-- @RELATION DEPENDS_ON -> [DashboardTaskHistory] -->
<!-- @RELATION DEPENDS_ON -> [GitManager] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:BranchSelector] -->
<!-- @RELATION DEPENDS_ON -> [CommitHistory] -->
<!-- @RELATION BINDS_TO -> [GitStatusModel] -->
<!-- @RELATION CALLS -> [EXT:frontend:api_module] -->
<!-- @UX_STATE Loading -> Skeleton shown while dashboard detail resolves. -->
<!-- @UX_STATE Loaded -> Metadata, assets, Git controls, and task history rendered. -->
<!-- @UX_STATE Validating -> LLM validation scoped to current dashboard. -->
<!-- @UX_STATE Error -> Inline error with retry. -->
<!-- @UX_FEEDBACK Toast notifications on backup/validation/Git operations. -->
<!-- @UX_STATE loading -> Skeleton shown. -->
<!-- @UX_STATE loaded -> Metadata, assets, Git controls, task history rendered. -->
<!-- @UX_STATE error -> Inline error with retry. -->
<script lang="ts">
/**
* @PURPOSE: Dashboard Detail View - Overview of charts and datasets linked to a dashboard.
* @LAYER UI
* @RELATION IMPLEMENTS -> [EXT:frontend:RoutePages]
* @RELATION IMPLEMENTS -> [PageContracts]
* @RELATION BINDS_TO -> [NavigationContracts]
* @RELATION DEPENDS_ON -> [EXT:frontend:api_module]
* @PRE: Route params include a dashboard reference and environment id before detail, Git, and validation requests are executed.
* @POST: Dashboard metadata, related assets, and task/Git panels remain synchronized for the selected dashboard context.
* @SIDE_EFFECT: Loads dashboard, thumbnail, task-history, LLM, and Git data; emits toasts; and may navigate to linked resources.
* @INVARIANT: Shows dashboard metadata, charts, and datasets for selected environment.
* @UX_STATE: Loading -> Skeleton and loading feedback remain visible while dashboard detail resolves.
* @UX_STATE: Loaded -> Dashboard metadata, linked assets, and Git controls render for the selected dashboard.
* @UX_STATE: Validating -> LLM validation actions stay scoped to the current dashboard context.
* @UX_STATE: Error -> Inline recovery keeps the user on the current dashboard route.
* @TEST_DATA: dashboard_detail_ready -> {"dashboard":{"id":11,"title":"Ops","chart_count":3,"dataset_count":2},"taskHistory":[{"id":"t-1","plugin_id":"llm_dashboard_validation","status":"SUCCESS"}],"llmStatus":{"configured":true,"reason":"ok"}}
* @TEST_DATA: llm_unconfigured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}}
*
* @TEST_CONTRACT Page_DashboardDetail ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Loads specific dashboard details directly via the API leveraging the id parameter",
* "Triggers LLM validation gracefully respecting llmReady status"
* ]
* }
* @TEST_FIXTURE init_state -> {"id": "1", "env_id": "env1"}
* @TEST_INVARIANT dashboard_fetching -> verifies: [init_state]
*/
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { t } from '$lib/i18n/index.svelte.js';
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
import { DashboardDetailModel } from '$lib/models/DashboardDetailModel.svelte';
import Icon from '$lib/ui/Icon.svelte';
import BranchSelector from '../../../components/git/BranchSelector.svelte';
import CommitHistory from '../../../components/git/CommitHistory.svelte';
import GitManager from '../../../components/git/GitManager.svelte';
import DashboardHeader from './components/DashboardHeader.svelte';
import DashboardGitManager from './components/DashboardGitManager.svelte';
import DashboardLinkedResources from './components/DashboardLinkedResources.svelte';
import DashboardTaskHistory from './components/DashboardTaskHistory.svelte';
import { onMount, onDestroy } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { t } from "$lib/i18n/index.svelte.js";
import { api } from "$lib/api.js";
import { ROUTES } from "$lib/routes.js";
import { GitStatusModel } from "$lib/models/GitStatusModel.svelte.ts";
import { openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js";
import { addToast } from "$lib/toasts.svelte.js";
import Icon from "$lib/ui/Icon.svelte";
import BranchSelector from "../../../components/git/BranchSelector.svelte";
import CommitHistory from "../../../components/git/CommitHistory.svelte";
import GitManager from "../../../components/git/GitManager.svelte";
import DashboardHeader from "./components/DashboardHeader.svelte";
import DashboardGitManager from "./components/DashboardGitManager.svelte";
import DashboardLinkedResources from "./components/DashboardLinkedResources.svelte";
import DashboardTaskHistory from "./components/DashboardTaskHistory.svelte";
const m = new DashboardDetailModel();
let dashboardRef = $derived(page.params.id);
let envId = $derived(page.url.searchParams.get("env_id") || "");
let dashboard = $state(null);
let gitDashboardRef = $derived(dashboard?.slug || dashboardRef || "");
let resolvedDashboardId = $derived(
dashboard?.id ??
(/^\d+$/.test(String(dashboardRef || "")) ? Number(dashboardRef) : null),
);
$effect(() => { m.dashboardRef = page.params.id; });
$effect(() => { m.envId = page.url.searchParams.get('env_id') || ''; });
let isLoading = $state(true);
let error = $state(null);
let taskHistory = $state([]);
let isTaskHistoryLoading = $state(false);
let taskHistoryError = $state(null);
let isStartingBackup = $state(false);
let thumbnailUrl = $state("");
let isThumbnailLoading = $state(false);
let thumbnailError = $state(null);
let activeTab = $state("resources");
const { formatDate, toTaskTypeLabel, getTaskStatusClasses, getValidationStatus, getValidationStatusClasses } = DashboardDetailModel;
// Validation history
let validationHistory = $state([]);
let isValidationHistoryLoading = $state(false);
let validationHistoryError = $state(null);
let showGitManager = $state(false);
let wasGitManagerOpen = $state(false);
onMount(async () => { await m.loadDashboardPage(); });
onDestroy(() => { m.cleanup(); });
const gitModel = new GitStatusModel();
$effect(() => {
gitModel.envId = envId || null;
});
onMount(async () => {
await loadDashboardPage();
});
onDestroy(() => {
releaseThumbnailUrl();
});
async function loadDashboardPage() {
await loadDashboardDetail();
gitModel.dashboardId = gitDashboardRef;
gitModel.envId = envId || null;
const effectiveDashboardRef = dashboard?.id ?? dashboardRef;
await Promise.all([
loadTaskHistory(effectiveDashboardRef),
loadThumbnail(false, effectiveDashboardRef),
loadValidationHistory(effectiveDashboardRef),
gitModel.loadStatus(),
]);
}
async function loadDashboardDetail() {
if (!dashboardRef || !envId) {
error = $t.dashboard?.missing_context;
isLoading = false;
return;
}
isLoading = true;
error = null;
try {
dashboard = await api.getDashboardDetail(envId, dashboardRef);
} catch (err) {
error = err.message || $t.dashboard?.load_detail_failed;
console.error("[DashboardDetail][Coherence:Failed]", err);
} finally {
isLoading = false;
}
}
async function loadTaskHistory(targetDashboardRef = dashboardRef) {
if (!targetDashboardRef || !envId) return;
isTaskHistoryLoading = true;
taskHistoryError = null;
try {
const response = await api.getDashboardTaskHistory(envId, targetDashboardRef, {
limit: 30,
});
taskHistory = response?.items || [];
} catch (err) {
taskHistoryError = err.message || "Failed to load task history";
taskHistory = [];
} finally {
isTaskHistoryLoading = false;
}
}
function releaseThumbnailUrl() {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
thumbnailUrl = "";
}
}
async function loadThumbnail(force = false, targetDashboardRef = dashboardRef) {
if (!targetDashboardRef || !envId) return;
isThumbnailLoading = true;
thumbnailError = null;
try {
const blob = await api.getDashboardThumbnail(envId, targetDashboardRef, {
force,
});
releaseThumbnailUrl();
thumbnailUrl = URL.createObjectURL(blob);
} catch (err) {
if (err?.status === 202) {
thumbnailError =
$t.dashboard?.thumbnail_generating || "Thumbnail is being generated";
} else {
thumbnailError =
err.message ||
$t.dashboard?.thumbnail_failed ||
"Failed to load thumbnail";
}
} finally {
isThumbnailLoading = false;
}
}
async function runBackupTask() {
if (isStartingBackup || !envId || !resolvedDashboardId) return;
isStartingBackup = true;
try {
const response = await api.postApi("/dashboards/backup", {
env_id: envId,
dashboard_ids: [resolvedDashboardId],
});
const taskId = response?.task_id;
if (taskId) {
openDrawerForTaskIfPreferred(taskId);
addToast(
$t.dashboard?.backup_started || "Backup task started",
"success",
);
}
await loadTaskHistory();
} catch (err) {
addToast(
err.message ||
$t.dashboard?.backup_task_failed ||
"Failed to start backup",
"error",
);
} finally {
isStartingBackup = false;
}
}
// (runLlmValidationTask and openLlmReport removed — replaced by Validation History section)
async function loadValidationHistory(targetDashboardRef = dashboardRef) {
if (!targetDashboardRef || !envId) return;
isValidationHistoryLoading = true;
validationHistoryError = null;
try {
const qs = new URLSearchParams();
qs.append('dashboard_id', String(targetDashboardRef));
qs.append('environment_id', envId);
qs.append('page_size', '10');
const { fetchApi } = await import('$lib/api');
const response = await fetchApi(`/validation-tasks/runs/all?${qs.toString()}`);
validationHistory = response?.items || response?.results || [];
} catch (err) {
validationHistoryError = err.message || "Failed to load validation history";
validationHistory = [];
} finally {
isValidationHistoryLoading = false;
}
}
function toTaskTypeLabel(pluginId) {
if (pluginId === "superset-backup") return $t.dashboard?.backup || "Backup";
if (pluginId === "llm_dashboard_validation")
return $t.dashboard?.llm_check || "LLM Check";
return pluginId || "-";
}
function getTaskStatusClasses(status) {
const normalized = (status || "").toLowerCase();
if (normalized === "running" || normalized === "pending")
return "bg-primary-light text-primary";
if (normalized === "success") return "bg-success-light text-success";
if (normalized === "failed" || normalized === "error")
return "bg-destructive-light text-destructive";
if (normalized === "awaiting_input" || normalized === "waiting_input")
return "bg-warning-light text-warning";
return "bg-surface-muted text-text";
}
function getValidationStatus(task) {
if (task?.plugin_id !== "llm_dashboard_validation") {
return { label: "-", level: "na", icon: "" };
}
const rawStatus = String(task?.validation_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: "?" };
}
function getValidationStatusClasses(level) {
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";
return "bg-surface-page text-text-subtle border-border";
}
function goBack() {
goto(ROUTES.dashboards.list(envId));
}
function openDataset(datasetId) {
goto(ROUTES.datasets.detail(datasetId, envId));
}
function formatDate(value) {
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return `${parsed.toLocaleDateString()} ${parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
async function handleSyncAndOpenCommit() {
const success = await gitModel.syncRepository();
if (success) showGitManager = true;
}
$effect(() => {
if (showGitManager) {
wasGitManagerOpen = true;
return;
}
if (!wasGitManagerOpen) return;
wasGitManagerOpen = false;
void gitModel.loadStatus();
});
$effect(() => { m.trackGitManagerClose(); });
</script>
<div class="mx-auto w-full max-w-7xl space-y-6">
<DashboardHeader
{dashboard}
{resolvedDashboardId}
{dashboardRef}
{envId}
{gitDashboardRef}
hasGitRepo={gitModel.hasGitRepo}
bind:currentBranch={gitModel.currentBranch}
gitMeta={gitModel.meta}
gitStatus={gitModel.gitStatus}
{isStartingBackup}
bind:showGitManager
{goBack}
handleBranchChange={(e) => 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}
<div
class="flex items-center justify-between rounded-lg border border-destructive-ring bg-destructive-light px-4 py-3 text-destructive"
>
<span>{error}</span>
<button
class="rounded bg-destructive px-3 py-1.5 text-white hover:bg-destructive-hover"
onclick={loadDashboardDetail}
>
{$t.common?.retry}
</button>
{#if m.error}
<div class="flex items-center justify-between rounded-lg border border-destructive-ring bg-destructive-light px-4 py-3 text-destructive">
<span>{m.error}</span>
<button class="rounded bg-destructive px-3 py-1.5 text-white hover:bg-destructive-hover" onclick={() => m.loadDashboardDetail()}>{$t.common?.retry}</button>
</div>
{/if}
{#if isLoading}
{#if m.isLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{#each Array(3) as _}
<div
class="h-24 animate-pulse rounded-xl border border-border bg-surface-card"
></div>
<div class="h-24 animate-pulse rounded-xl border border-border bg-surface-card"></div>
{/each}
</div>
<div
class="h-64 animate-pulse rounded-xl border border-border bg-surface-card"
></div>
{:else if dashboard}
<div class="h-64 animate-pulse rounded-xl border border-border bg-surface-card"></div>
{:else if m.dashboard}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-5">
<!-- Thumbnail -->
<div class="rounded-xl border border-border bg-surface-card p-4 xl:col-span-2">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}
</h2>
<button
class="rounded-md border border-border-strong px-2 py-1 text-xs text-text hover:bg-surface-page"
onclick={() => loadThumbnail(true)}
disabled={isThumbnailLoading}
>
{$t.common?.refresh || "Refresh"}
</button>
<h2 class="text-sm font-semibold uppercase tracking-wide text-text-muted">{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}</h2>
<button class="rounded-md border border-border-strong px-2 py-1 text-xs text-text hover:bg-surface-page" onclick={() => m.loadThumbnail(true)} disabled={m.isThumbnailLoading}>{$t.common?.refresh}</button>
</div>
{#if isThumbnailLoading}
{#if m.isThumbnailLoading}
<div class="h-56 animate-pulse rounded-lg bg-surface-muted"></div>
{:else if thumbnailUrl}
<img
src={thumbnailUrl}
alt="Dashboard thumbnail"
class="h-56 w-full rounded-lg border border-border object-cover"
/>
{:else if m.thumbnailUrl}
<img src={m.thumbnailUrl} alt="Dashboard thumbnail" class="h-56 w-full rounded-lg border border-border object-cover" />
{:else}
<div
class="flex h-56 items-center justify-center rounded-lg border border-dashed border-border-strong bg-surface-page text-sm text-text-muted"
>
{thumbnailError ||
$t.dashboard?.thumbnail_unavailable ||
"Thumbnail is unavailable"}
</div>
<div class="flex h-56 items-center justify-center rounded-lg border border-dashed border-border-strong bg-surface-page text-sm text-text-muted">{m.thumbnailError || $t.dashboard?.thumbnail_unavailable || "Thumbnail is unavailable"}</div>
{/if}
</div>
<!-- Git Manager -->
<DashboardGitManager
isGitStatusLoading={gitModel.statusLoading}
gitStatusError={gitModel.statusError}
hasGitRepo={gitModel.hasGitRepo}
gitSyncState={gitModel.syncState}
gitStatus={gitModel.gitStatus}
changedChartsCount={gitModel.changedChartsCount}
changedDatasetsCount={gitModel.changedDatasetsCount}
allChangedFiles={gitModel.allChangedFilesList}
runGitSyncAndOpenCommit={handleSyncAndOpenCommit}
isSyncingGit={gitModel.syncing}
hasChangesToCommit={gitModel.hasChangesToCommit}
loadGitDiffPreview={() => 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}
/>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-border bg-surface-card p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.last_modified}
</p>
<p class="mt-2 text-lg font-semibold text-text">
{formatDate(dashboard.last_modified)}
</p>
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.dashboard?.last_modified}</p>
<p class="mt-2 text-lg font-semibold text-text">{formatDate(m.dashboard.last_modified)}</p>
</div>
<div class="rounded-xl border border-border bg-surface-card p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.charts}
</p>
<p class="mt-2 text-lg font-semibold text-text">
{dashboard.chart_count || 0}
</p>
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.dashboard?.charts}</p>
<p class="mt-2 text-lg font-semibold text-text">{m.dashboard.chart_count || 0}</p>
</div>
<div class="rounded-xl border border-border bg-surface-card p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{$t.nav?.datasets}
</p>
<p class="mt-2 text-lg font-semibold text-text">
{dashboard.dataset_count || 0}
</p>
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.nav?.datasets}</p>
<p class="mt-2 text-lg font-semibold text-text">{m.dashboard.dataset_count || 0}</p>
</div>
</div>
<!-- Validation History Section -->
<!-- Validation History -->
<div class="rounded-xl border border-border bg-surface-card p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.validation_history || "Validation History"}
</h2>
<button
class="rounded-md border border-border-strong px-2 py-1 text-xs text-text hover:bg-surface-page"
onclick={() => loadValidationHistory(dashboard?.id ?? dashboardRef)}
disabled={isValidationHistoryLoading}
>
{$t.common?.refresh}
</button>
<h2 class="text-sm font-semibold uppercase tracking-wide text-text-muted">{$t.dashboard?.validation_history || "Validation History"}</h2>
<button class="rounded-md border border-border-strong px-2 py-1 text-xs text-text hover:bg-surface-page" onclick={() => m.loadValidationHistory(m.dashboard?.id ?? m.dashboardRef)} disabled={m.isValidationHistoryLoading}>{$t.common?.refresh}</button>
</div>
{#if isValidationHistoryLoading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-muted"></div>
{/each}
</div>
{:else if validationHistoryError}
<div class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-2 text-sm text-destructive">
{validationHistoryError}
</div>
{:else if validationHistory.length === 0}
<div class="rounded-lg border border-border bg-surface-page px-3 py-6 text-center text-sm text-text-muted">
{$t.dashboard?.no_validation_runs || "No validation runs yet"}
</div>
{#if m.isValidationHistoryLoading}
<div class="space-y-2">{#each Array(3) as _}<div class="h-10 animate-pulse rounded bg-surface-muted"></div>{/each}</div>
{:else if m.validationHistoryError}
<div class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-2 text-sm text-destructive">{m.validationHistoryError}</div>
{:else if m.validationHistory.length === 0}
<div class="rounded-lg border border-border bg-surface-page px-3 py-6 text-center text-sm text-text-muted">{$t.dashboard?.no_validation_runs || "No validation runs yet"}</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border text-sm">
<thead class="bg-surface-page">
<tr>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.date}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.tasks?.name || "Task Name"}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.status}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.actions}</th>
</tr>
</thead>
<thead class="bg-surface-page"><tr>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.date}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.tasks?.name || "Task Name"}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.status}</th>
<th class="px-3 py-2 text-left font-semibold text-text-muted">{$t.common?.actions}</th>
</tr></thead>
<tbody class="divide-y divide-border">
{#each validationHistory as run}
{#each m.validationHistory as run}
{@const runStatus = String(run.status || "").toUpperCase()}
<tr>
<td class="px-3 py-2 text-text">{formatDate(run.timestamp || run.created_at || run.started_at)}</td>
<td class="px-3 py-2 text-text">{run.summary ? run.summary.slice(0, 80) + (run.summary.length > 80 ? '' : '') : '-'}</td>
<td class="px-3 py-2 text-text">{formatDate(run.timestamp || (run as Record<string,unknown>).created_at || run.started_at)}</td>
<td class="px-3 py-2 text-text">{run.summary ? run.summary.slice(0, 80) + (run.summary.length > 80 ? '\u2026' : '') : '-'}</td>
<td class="px-3 py-2">
<span
class={`rounded-full px-2 py-1 text-xs font-semibold uppercase ${runStatus === "SUCCESS" || runStatus === "PASS" ? "bg-success-light text-success" : runStatus === "FAIL" || runStatus === "FAILED" || runStatus === "ERROR" ? "bg-destructive-light text-destructive" : runStatus === "WARN" ? "bg-warning-light text-warning" : runStatus === "RUNNING" || runStatus === "PENDING" ? "bg-primary-light text-primary" : "bg-surface-muted text-text"}`}
>
{runStatus || "UNKNOWN"}
</span>
<span class="rounded-full px-2 py-1 text-xs font-semibold uppercase {runStatus === 'SUCCESS' || runStatus === 'PASS' ? 'bg-success-light text-success' : runStatus === 'FAIL' || runStatus === 'FAILED' || runStatus === 'ERROR' ? 'bg-destructive-light text-destructive' : runStatus === 'WARN' ? 'bg-warning-light text-warning' : runStatus === 'RUNNING' || runStatus === 'PENDING' ? 'bg-primary-light text-primary' : 'bg-surface-muted text-text'}">{runStatus || 'UNKNOWN'}</span>
</td>
<td class="px-3 py-2">
{#if run.policy_id || run.policyId}
<a
href={ROUTES.validationTasks.runReport(run.policy_id || run.policyId, run.id || run.run_id)}
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"}
</a>
{#if run.policy_id || (run as Record<string,unknown>).policyId}
<a href={ROUTES.validationTasks.runReport(run.policy_id || (run as Record<string,unknown>).policyId as string, run.id || (run as Record<string,unknown>).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"}</a>
{:else if run.id}
<button
class="inline-flex items-center gap-1 rounded-md border border-border-strong px-2 py-1 text-xs text-text-muted hover:bg-surface-page"
onclick={() => openDrawerForTaskIfPreferred(run.id)}
>
{$t.tasks?.open_task_drawer || "View task"}
</button>
<button class="inline-flex items-center gap-1 rounded-md border border-border-strong px-2 py-1 text-xs text-text-muted hover:bg-surface-page" onclick={() => openDrawerForTaskIfPreferred(run.id)}>{$t.tasks?.open_task_drawer || "View task"}</button>
{/if}
</td>
</tr>
@@ -518,52 +171,30 @@
</tbody>
</table>
</div>
<!-- View all link -->
<div class="border-t border-border px-3 py-2 text-right">
<a
href={ROUTES.dashboards.validation(resolvedDashboardId ?? dashboardRef, envId || '')}
class="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary transition-colors"
>
<a href={ROUTES.dashboards.validation(m.resolvedDashboardId ?? m.dashboardRef, m.envId || '')} class="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary transition-colors">
{$t.dashboard?.view_all_validation || 'View all validation runs'}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</a>
</div>
{/if}
</div>
<!-- Tabs: Linked Resources / Git History -->
<div class="rounded-xl border border-border bg-surface-card">
<div class="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<button
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === "resources" ? "bg-terminal-bg text-white" : "bg-surface-muted text-text hover:bg-surface-muted"}`}
onclick={() => (activeTab = "resources")}
>
{$t.dashboard?.linked_resources || "Linked resources"}
</button>
<button
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === "git-history" ? "bg-terminal-bg text-white" : "bg-surface-muted text-text hover:bg-surface-muted"}`}
onclick={() => (activeTab = "git-history")}
>
{$t.dashboard?.git_history || "Git history"}
</button>
<button class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {m.activeTab === 'resources' ? 'bg-terminal-bg text-white' : 'bg-surface-muted text-text hover:bg-surface-muted'}" onclick={() => (m.activeTab = 'resources')}>{$t.dashboard?.linked_resources || 'Linked resources'}</button>
<button class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {m.activeTab === 'git-history' ? 'bg-terminal-bg text-white' : 'bg-surface-muted text-text hover:bg-surface-muted'}" onclick={() => (m.activeTab = 'git-history')}>{$t.dashboard?.git_history || 'Git history'}</button>
</div>
<div class="space-y-4 p-4">
{#if activeTab === "resources"}
<DashboardLinkedResources
{dashboard}
{openDataset}
{formatDate}
/>
{:else if activeTab === "git-history"}
{#if gitModel.hasGitRepo && gitDashboardRef}
<CommitHistory dashboardId={gitDashboardRef} envId={envId || null} />
{#if m.activeTab === 'resources'}
<DashboardLinkedResources dashboard={m.dashboard} openDataset={(id: string) => m.openDataset(id)} {formatDate} />
{:else if m.activeTab === 'git-history'}
{#if m.gitModel.hasGitRepo && m.gitDashboardRef}
<CommitHistory dashboardId={m.gitDashboardRef} envId={m.envId || null} />
{:else}
<div class="rounded-lg border border-border bg-surface-page px-4 py-6 text-sm text-text-muted">
{$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."}
</div>
<div class="rounded-lg border border-border bg-surface-page px-4 py-6 text-sm text-text-muted">{$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."}</div>
{/if}
{/if}
</div>
@@ -571,13 +202,7 @@
{/if}
</div>
{#if showGitManager && gitDashboardRef}
<GitManager
dashboardId={gitDashboardRef}
envId={envId || null}
dashboardTitle={dashboard?.title || ""}
bind:show={showGitManager}
/>
{#if m.showGitManager && m.gitDashboardRef}
<GitManager dashboardId={m.gitDashboardRef} envId={m.envId || null} dashboardTitle={m.dashboard?.title || ''} bind:show={m.showGitManager} />
{/if}
<!-- #endregion DashboardDetail -->