причесываем лог
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -122,12 +122,49 @@ 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
|
||||||
# @PRE: env is a valid Environment object
|
# @PRE: env is a valid Environment object
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ 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) =>
|
||||||
|
fetchApiBlob(`/storage/file?path=${encodeURIComponent(path)}`),
|
||||||
|
|
||||||
// Dashboards
|
// Dashboards
|
||||||
getDashboards: (envId, options = {}) => {
|
getDashboards: (envId, options = {}) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]}
|
||||||
>
|
>
|
||||||
|
{#if screenshotBlobUrls[path]}
|
||||||
<img
|
<img
|
||||||
src={`/api/storage/file?path=${encodeURIComponent(path)}`}
|
src={screenshotBlobUrls[path]}
|
||||||
alt="Validation screenshot"
|
alt="Validation screenshot"
|
||||||
class="h-64 w-full rounded-lg border border-slate-200 object-cover"
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user