причесываем лог

This commit is contained in:
2026-02-28 10:47:19 +03:00
parent 7e43830144
commit daa9f7be3a
5 changed files with 172 additions and 26 deletions

View File

@@ -33,22 +33,43 @@ async def test_get_dashboards_with_status():
] ]
# Mock tasks # Mock tasks
mock_task = MagicMock() task_prod_old = MagicMock()
mock_task.id = "task-123" task_prod_old.id = "task-123"
mock_task.status = "SUCCESS" task_prod_old.plugin_id = "llm_dashboard_validation"
mock_task.params = {"resource_id": "dashboard-1"} task_prod_old.status = "SUCCESS"
mock_task.created_at = datetime.now() 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 = MagicMock()
env.id = "prod" 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 len(result) == 2
assert result[0]["id"] == 1 assert result[0]["id"] == 1
assert "git_status" in result[0] assert "git_status" in result[0]
assert "last_task" 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] # [/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:test_get_last_task_for_resource_no_match:Function]
# [/DEF:backend.src.services.__tests__.test_resource_service:Module] # [/DEF:backend.src.services.__tests__.test_resource_service:Module]

View File

@@ -122,11 +122,48 @@ class ResourceService:
) )
last_task = max(matched_tasks, key=_task_time) 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 { return {
"task_id": str(getattr(last_task, "id", "")), "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:_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] # [DEF:get_datasets_with_status:Function]
# @PURPOSE: Fetch datasets from environment with mapping progress and last task status # @PURPOSE: Fetch datasets from environment with mapping progress and last task status

View File

@@ -240,8 +240,10 @@ export const api = {
getEnvironmentsList: () => fetchApi('/environments'), getEnvironmentsList: () => fetchApi('/environments'),
getLlmStatus: () => fetchApi('/llm/status'), getLlmStatus: () => fetchApi('/llm/status'),
getEnvironmentDatabases: (id) => fetchApi(`/environments/${id}/databases`), getEnvironmentDatabases: (id) => fetchApi(`/environments/${id}/databases`),
getStorageFileBlob: (path) =>
// Dashboards fetchApiBlob(`/storage/file?path=${encodeURIComponent(path)}`),
// Dashboards
getDashboards: (envId, options = {}) => { getDashboards: (envId, options = {}) => {
const params = new URLSearchParams({ env_id: envId }); const params = new URLSearchParams({ env_id: envId });
if (options.search) params.append('search', options.search); if (options.search) params.append('search', options.search);

View File

@@ -169,6 +169,23 @@
return byName?.id || rawEnvIdOrName; 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() { async function loadActiveTaskDetails() {
const taskId = normalizeTaskId(activeTaskId); const taskId = normalizeTaskId(activeTaskId);
if (!taskId) return; if (!taskId) return;
@@ -295,7 +312,17 @@
); );
return; 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"); window.open(href, "_blank", "noopener,noreferrer");
} }

View File

@@ -27,7 +27,7 @@
* @TEST_FIXTURE init_state -> {"taskId": "task-1"} * @TEST_FIXTURE init_state -> {"taskId": "task-1"}
* @TEST_INVARIANT correct_fetch -> verifies: [init_state] * @TEST_INVARIANT correct_fetch -> verifies: [init_state]
*/ */
import { onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { api } from "$lib/api.js"; import { api } from "$lib/api.js";
@@ -41,6 +41,9 @@
let logs = []; let logs = [];
let isLoading = true; let isLoading = true;
let error = null; let error = null;
let screenshotBlobUrls = {};
let screenshotLoadErrors = {};
let screenshotLoadToken = 0;
function formatDate(value) { function formatDate(value) {
if (!value) return "-"; if (!value) return "-";
@@ -102,10 +105,57 @@
openDrawerForTask(taskId); 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 () => { onMount(async () => {
await loadReport(); await loadReport();
}); });
onDestroy(() => {
cleanupScreenshotBlobUrls();
});
$: result = task?.result || {}; $: result = task?.result || {};
$: checkResult = getDashboardCheckResult(result); $: checkResult = getDashboardCheckResult(result);
$: timings = result?.timings || {}; $: timings = result?.timings || {};
@@ -114,6 +164,7 @@
: result?.screenshot_path : result?.screenshot_path
? [result.screenshot_path] ? [result.screenshot_path]
: []; : [];
$: void loadScreenshotBlobUrls(screenshotPaths);
$: sentLogs = Array.isArray(result?.logs_sent_to_llm) $: sentLogs = Array.isArray(result?.logs_sent_to_llm)
? result.logs_sent_to_llm ? result.logs_sent_to_llm
: []; : [];
@@ -256,19 +307,27 @@
{:else} {:else}
<div class="mt-3 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="mt-3 grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each screenshotPaths as path} {#each screenshotPaths as path}
<a <button
href={`/api/storage/file?path=${encodeURIComponent(path)}`} type="button"
target="_blank" class="block w-full text-left"
rel="noreferrer noopener" on:click={() => openScreenshot(path)}
class="block" disabled={!screenshotBlobUrls[path]}
> >
<img {#if screenshotBlobUrls[path]}
src={`/api/storage/file?path=${encodeURIComponent(path)}`} <img
alt="Validation screenshot" src={screenshotBlobUrls[path]}
class="h-64 w-full rounded-lg border border-slate-200 object-cover" alt="Validation screenshot"
/> class="h-64 w-full rounded-lg border border-slate-200 object-cover"
/>
{:else}
<div
class="flex h-64 w-full items-center justify-center rounded-lg border border-rose-200 bg-rose-50 px-4 text-sm text-rose-700"
>
{screenshotLoadErrors[path] || "Failed to load screenshot"}
</div>
{/if}
<p class="mt-1 truncate text-xs text-slate-500">{path}</p> <p class="mt-1 truncate text-xs text-slate-500">{path}</p>
</a> </button>
{/each} {/each}
</div> </div>
{/if} {/if}