diff --git a/backend/src/services/__tests__/test_resource_service.py b/backend/src/services/__tests__/test_resource_service.py index 1a4819c..d8c5c76 100644 --- a/backend/src/services/__tests__/test_resource_service.py +++ b/backend/src/services/__tests__/test_resource_service.py @@ -33,22 +33,43 @@ async def test_get_dashboards_with_status(): ] # Mock tasks - mock_task = MagicMock() - mock_task.id = "task-123" - mock_task.status = "SUCCESS" - mock_task.params = {"resource_id": "dashboard-1"} - mock_task.created_at = datetime.now() - + task_prod_old = MagicMock() + task_prod_old.id = "task-123" + task_prod_old.plugin_id = "llm_dashboard_validation" + task_prod_old.status = "SUCCESS" + task_prod_old.params = {"dashboard_id": "1", "environment_id": "prod"} + task_prod_old.started_at = datetime(2024, 1, 1, 10, 0, 0) + + task_prod_new = MagicMock() + task_prod_new.id = "task-124" + task_prod_new.plugin_id = "llm_dashboard_validation" + task_prod_new.status = "TaskStatus.FAILED" + task_prod_new.params = {"dashboard_id": "1", "environment_id": "prod"} + task_prod_new.result = {"status": "FAIL"} + task_prod_new.started_at = datetime(2024, 1, 1, 12, 0, 0) + + task_other_env = MagicMock() + task_other_env.id = "task-200" + task_other_env.plugin_id = "llm_dashboard_validation" + task_other_env.status = "SUCCESS" + task_other_env.params = {"dashboard_id": "1", "environment_id": "stage"} + task_other_env.started_at = datetime(2024, 1, 1, 13, 0, 0) + env = MagicMock() env.id = "prod" - - result = await service.get_dashboards_with_status(env, [mock_task]) + + result = await service.get_dashboards_with_status( + env, + [task_prod_old, task_prod_new, task_other_env], + ) assert len(result) == 2 assert result[0]["id"] == 1 assert "git_status" in result[0] assert "last_task" in result[0] - assert result[0]["last_task"]["task_id"] == "task-123" + assert result[0]["last_task"]["task_id"] == "task-124" + assert result[0]["last_task"]["status"] == "FAILED" + assert result[0]["last_task"]["validation_status"] == "FAIL" # [/DEF:test_get_dashboards_with_status:Function] @@ -248,4 +269,4 @@ def test_get_last_task_for_resource_no_match(): # [/DEF:test_get_last_task_for_resource_no_match:Function] -# [/DEF:backend.src.services.__tests__.test_resource_service:Module] \ No newline at end of file +# [/DEF:backend.src.services.__tests__.test_resource_service:Module] diff --git a/backend/src/services/resource_service.py b/backend/src/services/resource_service.py index d053305..286018b 100644 --- a/backend/src/services/resource_service.py +++ b/backend/src/services/resource_service.py @@ -122,11 +122,48 @@ class ResourceService: ) last_task = max(matched_tasks, key=_task_time) + raw_result = getattr(last_task, "result", None) + validation_status = None + if isinstance(raw_result, dict): + validation_status = self._normalize_validation_status(raw_result.get("status")) + return { "task_id": str(getattr(last_task, "id", "")), - "status": str(getattr(last_task, "status", "")), + "status": self._normalize_task_status(getattr(last_task, "status", "")), + "validation_status": validation_status, } # [/DEF:_get_last_llm_task_for_dashboard:Function] + + # [DEF:_normalize_task_status:Function] + # @PURPOSE: Normalize task status to stable uppercase values for UI/API projections + # @PRE: raw_status can be enum or string + # @POST: Returns uppercase status without enum class prefix + # @PARAM: raw_status (Any) - Raw task status object/value + # @RETURN: str - Normalized status token + def _normalize_task_status(self, raw_status: Any) -> str: + if raw_status is None: + return "" + value = getattr(raw_status, "value", raw_status) + status_text = str(value).strip() + if "." in status_text: + status_text = status_text.split(".")[-1] + return status_text.upper() + # [/DEF:_normalize_task_status:Function] + + # [DEF:_normalize_validation_status:Function] + # @PURPOSE: Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN + # @PRE: raw_status can be any scalar type + # @POST: Returns normalized validation status token or None + # @PARAM: raw_status (Any) - Raw validation status from task result + # @RETURN: Optional[str] - PASS|FAIL|WARN|UNKNOWN + def _normalize_validation_status(self, raw_status: Any) -> Optional[str]: + if raw_status is None: + return None + status_text = str(raw_status).strip().upper() + if status_text in {"PASS", "FAIL", "WARN"}: + return status_text + return "UNKNOWN" + # [/DEF:_normalize_validation_status:Function] # [DEF:get_datasets_with_status:Function] # @PURPOSE: Fetch datasets from environment with mapping progress and last task status diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e8be864..0a3578b 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -240,8 +240,10 @@ export const api = { getEnvironmentsList: () => fetchApi('/environments'), getLlmStatus: () => fetchApi('/llm/status'), getEnvironmentDatabases: (id) => fetchApi(`/environments/${id}/databases`), - - // Dashboards + getStorageFileBlob: (path) => + fetchApiBlob(`/storage/file?path=${encodeURIComponent(path)}`), + + // Dashboards getDashboards: (envId, options = {}) => { const params = new URLSearchParams({ env_id: envId }); if (options.search) params.append('search', options.search); diff --git a/frontend/src/lib/components/layout/TaskDrawer.svelte b/frontend/src/lib/components/layout/TaskDrawer.svelte index a87d896..f161ee9 100644 --- a/frontend/src/lib/components/layout/TaskDrawer.svelte +++ b/frontend/src/lib/components/layout/TaskDrawer.svelte @@ -169,6 +169,23 @@ return byName?.id || rawEnvIdOrName; } + function normalizeSupersetBaseUrl(rawUrl) { + const baseUrl = String(rawUrl || "").trim().replace(/\/+$/, ""); + if (!baseUrl) return null; + if (baseUrl.endsWith("/api/v1")) { + return baseUrl.slice(0, -"/api/v1".length); + } + return baseUrl; + } + + function resolveSupersetDashboardUrl(envId, dashboardId) { + if (!envId || !dashboardId) return null; + const env = environmentOptions.find((item) => item.id === envId); + const baseUrl = normalizeSupersetBaseUrl(env?.url); + if (!baseUrl) return null; + return `${baseUrl}/superset/dashboard/${encodeURIComponent(String(dashboardId))}/`; + } + async function loadActiveTaskDetails() { const taskId = normalizeTaskId(activeTaskId); if (!taskId) return; @@ -295,7 +312,17 @@ ); return; } - const href = `/dashboards/${encodeURIComponent(String(taskSummary.primaryDashboardId))}?env_id=${encodeURIComponent(String(taskSummary.targetEnvId))}`; + const href = resolveSupersetDashboardUrl( + taskSummary.targetEnvId, + taskSummary.primaryDashboardId, + ); + if (!href) { + addToast( + $t.tasks?.summary_link_unavailable || "Deep link unavailable", + "error", + ); + return; + } window.open(href, "_blank", "noopener,noreferrer"); } diff --git a/frontend/src/routes/reports/llm/[taskId]/+page.svelte b/frontend/src/routes/reports/llm/[taskId]/+page.svelte index 8b59112..ac1d1eb 100644 --- a/frontend/src/routes/reports/llm/[taskId]/+page.svelte +++ b/frontend/src/routes/reports/llm/[taskId]/+page.svelte @@ -27,7 +27,7 @@ * @TEST_FIXTURE init_state -> {"taskId": "task-1"} * @TEST_INVARIANT correct_fetch -> verifies: [init_state] */ - import { onMount } from "svelte"; + import { onDestroy, onMount } from "svelte"; import { page } from "$app/stores"; import { goto } from "$app/navigation"; import { api } from "$lib/api.js"; @@ -41,6 +41,9 @@ let logs = []; let isLoading = true; let error = null; + let screenshotBlobUrls = {}; + let screenshotLoadErrors = {}; + let screenshotLoadToken = 0; function formatDate(value) { if (!value) return "-"; @@ -102,10 +105,57 @@ openDrawerForTask(taskId); } + function cleanupScreenshotBlobUrls() { + Object.values(screenshotBlobUrls).forEach((url) => { + if (url) URL.revokeObjectURL(url); + }); + screenshotBlobUrls = {}; + } + + async function loadScreenshotBlobUrls(paths) { + const currentToken = ++screenshotLoadToken; + cleanupScreenshotBlobUrls(); + screenshotLoadErrors = {}; + + if (!Array.isArray(paths) || paths.length === 0) return; + + const nextUrls = {}; + const nextErrors = {}; + + await Promise.all( + paths.map(async (path) => { + try { + const blob = await api.getStorageFileBlob(path); + nextUrls[path] = URL.createObjectURL(blob); + } catch (err) { + nextErrors[path] = err?.message || "Failed to load screenshot"; + } + }), + ); + + if (currentToken !== screenshotLoadToken) { + Object.values(nextUrls).forEach((url) => URL.revokeObjectURL(url)); + return; + } + + screenshotBlobUrls = nextUrls; + screenshotLoadErrors = nextErrors; + } + + function openScreenshot(path) { + const url = screenshotBlobUrls[path]; + if (!url) return; + window.open(url, "_blank", "noopener,noreferrer"); + } + onMount(async () => { await loadReport(); }); + onDestroy(() => { + cleanupScreenshotBlobUrls(); + }); + $: result = task?.result || {}; $: checkResult = getDashboardCheckResult(result); $: timings = result?.timings || {}; @@ -114,6 +164,7 @@ : result?.screenshot_path ? [result.screenshot_path] : []; + $: void loadScreenshotBlobUrls(screenshotPaths); $: sentLogs = Array.isArray(result?.logs_sent_to_llm) ? result.logs_sent_to_llm : []; @@ -256,19 +307,27 @@ {:else}
{/if}