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:
311
frontend/src/lib/models/DashboardDetailModel.svelte.ts
Normal file
311
frontend/src/lib/models/DashboardDetailModel.svelte.ts
Normal 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
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user