Improve dashboard LLM validation UX and report flow
This commit is contained in:
@@ -11,12 +11,16 @@
|
||||
# @INVARIANT: All dashboard responses include git_status and last_task metadata
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Optional, Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from pydantic import BaseModel, Field
|
||||
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.utils.network import DashboardNotFoundError
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
|
||||
@@ -90,6 +94,24 @@ class DashboardDetailResponse(BaseModel):
|
||||
dataset_count: int
|
||||
# [/DEF:DashboardDetailResponse:DataClass]
|
||||
|
||||
# [DEF:DashboardTaskHistoryItem:DataClass]
|
||||
class DashboardTaskHistoryItem(BaseModel):
|
||||
id: str
|
||||
plugin_id: str
|
||||
status: str
|
||||
validation_status: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
env_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
# [/DEF:DashboardTaskHistoryItem:DataClass]
|
||||
|
||||
# [DEF:DashboardTaskHistoryResponse:DataClass]
|
||||
class DashboardTaskHistoryResponse(BaseModel):
|
||||
dashboard_id: int
|
||||
items: List[DashboardTaskHistoryItem]
|
||||
# [/DEF:DashboardTaskHistoryResponse:DataClass]
|
||||
|
||||
# [DEF:DatabaseMapping:DataClass]
|
||||
class DatabaseMapping(BaseModel):
|
||||
source_db: str
|
||||
@@ -259,6 +281,190 @@ async def get_dashboard_detail(
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
|
||||
# [DEF:_task_matches_dashboard:Function]
|
||||
# @PURPOSE: Checks whether task params are tied to a specific dashboard and environment.
|
||||
# @PRE: task-like object exposes plugin_id and params fields.
|
||||
# @POST: Returns True only for supported task plugins tied to dashboard_id (+optional env_id).
|
||||
def _task_matches_dashboard(task: Any, dashboard_id: int, env_id: Optional[str]) -> bool:
|
||||
plugin_id = getattr(task, "plugin_id", None)
|
||||
if plugin_id not in {"superset-backup", "llm_dashboard_validation"}:
|
||||
return False
|
||||
|
||||
params = getattr(task, "params", {}) or {}
|
||||
dashboard_id_str = str(dashboard_id)
|
||||
|
||||
if plugin_id == "llm_dashboard_validation":
|
||||
task_dashboard_id = params.get("dashboard_id")
|
||||
if str(task_dashboard_id) != dashboard_id_str:
|
||||
return False
|
||||
if env_id:
|
||||
task_env = params.get("environment_id")
|
||||
return str(task_env) == str(env_id)
|
||||
return True
|
||||
|
||||
# superset-backup can pass dashboards as "dashboard_ids" or "dashboards"
|
||||
dashboard_ids = params.get("dashboard_ids") or params.get("dashboards") or []
|
||||
normalized_ids = {str(item) for item in dashboard_ids}
|
||||
if dashboard_id_str not in normalized_ids:
|
||||
return False
|
||||
if env_id:
|
||||
task_env = params.get("environment_id") or params.get("env")
|
||||
return str(task_env) == str(env_id)
|
||||
return True
|
||||
# [/DEF:_task_matches_dashboard:Function]
|
||||
|
||||
|
||||
# [DEF:get_dashboard_tasks_history:Function]
|
||||
# @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard.
|
||||
# @PRE: dashboard_id is valid integer.
|
||||
# @POST: Response contains sorted task history (newest first).
|
||||
@router.get("/{dashboard_id:int}/tasks", response_model=DashboardTaskHistoryResponse)
|
||||
async def get_dashboard_tasks_history(
|
||||
dashboard_id: int,
|
||||
env_id: Optional[str] = None,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
task_manager=Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboard_tasks_history", f"dashboard_id={dashboard_id}, env_id={env_id}, limit={limit}"):
|
||||
matching_tasks = []
|
||||
for task in task_manager.get_all_tasks():
|
||||
if _task_matches_dashboard(task, dashboard_id, env_id):
|
||||
matching_tasks.append(task)
|
||||
|
||||
def _sort_key(task_obj: Any) -> str:
|
||||
return (
|
||||
str(getattr(task_obj, "started_at", "") or "")
|
||||
or str(getattr(task_obj, "finished_at", "") or "")
|
||||
)
|
||||
|
||||
matching_tasks.sort(key=_sort_key, reverse=True)
|
||||
selected = matching_tasks[:limit]
|
||||
|
||||
items = []
|
||||
for task in selected:
|
||||
result = getattr(task, "result", None)
|
||||
summary = None
|
||||
validation_status = None
|
||||
if isinstance(result, dict):
|
||||
raw_validation_status = result.get("status")
|
||||
if raw_validation_status is not None:
|
||||
validation_status = str(raw_validation_status)
|
||||
summary = (
|
||||
result.get("summary")
|
||||
or result.get("status")
|
||||
or result.get("message")
|
||||
)
|
||||
params = getattr(task, "params", {}) or {}
|
||||
items.append(
|
||||
DashboardTaskHistoryItem(
|
||||
id=str(getattr(task, "id", "")),
|
||||
plugin_id=str(getattr(task, "plugin_id", "")),
|
||||
status=str(getattr(task, "status", "")),
|
||||
validation_status=validation_status,
|
||||
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
|
||||
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
|
||||
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard {dashboard_id}")
|
||||
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
|
||||
# [/DEF:get_dashboard_tasks_history:Function]
|
||||
|
||||
|
||||
# [DEF:get_dashboard_thumbnail:Function]
|
||||
# @PURPOSE: Proxies Superset dashboard thumbnail with cache support.
|
||||
# @PRE: env_id must exist.
|
||||
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
|
||||
@router.get("/{dashboard_id:int}/thumbnail")
|
||||
async def get_dashboard_thumbnail(
|
||||
dashboard_id: int,
|
||||
env_id: str,
|
||||
force: bool = Query(False),
|
||||
config_manager=Depends(get_config_manager),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboard_thumbnail", f"dashboard_id={dashboard_id}, env_id={env_id}, force={force}"):
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
digest = None
|
||||
thumb_endpoint = None
|
||||
|
||||
# Preferred flow (newer Superset): ask server to cache screenshot and return digest/image_url.
|
||||
try:
|
||||
screenshot_payload = client.network.request(
|
||||
method="POST",
|
||||
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
|
||||
json={"force": force},
|
||||
)
|
||||
payload = screenshot_payload.get("result", screenshot_payload) if isinstance(screenshot_payload, dict) else {}
|
||||
image_url = payload.get("image_url", "") if isinstance(payload, dict) else ""
|
||||
if isinstance(image_url, str) and image_url:
|
||||
matched = re.search(r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$", image_url)
|
||||
if matched:
|
||||
digest = matched.group(1)
|
||||
except DashboardNotFoundError:
|
||||
logger.warning(
|
||||
"[get_dashboard_thumbnail][Fallback] cache_dashboard_screenshot endpoint unavailable, fallback to dashboard.thumbnail_url"
|
||||
)
|
||||
|
||||
# Fallback flow (older Superset): read thumbnail_url from dashboard payload.
|
||||
if not digest:
|
||||
dashboard_payload = client.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}",
|
||||
)
|
||||
dashboard_data = dashboard_payload.get("result", dashboard_payload) if isinstance(dashboard_payload, dict) else {}
|
||||
thumbnail_url = dashboard_data.get("thumbnail_url", "") if isinstance(dashboard_data, dict) else ""
|
||||
if isinstance(thumbnail_url, str) and thumbnail_url:
|
||||
parsed = urlparse(thumbnail_url)
|
||||
parsed_path = parsed.path or thumbnail_url
|
||||
if parsed_path.startswith("/api/v1/"):
|
||||
parsed_path = parsed_path[len("/api/v1"):]
|
||||
thumb_endpoint = parsed_path
|
||||
matched = re.search(r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$", parsed_path)
|
||||
if matched:
|
||||
digest = matched.group(1)
|
||||
|
||||
if not thumb_endpoint:
|
||||
thumb_endpoint = f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
|
||||
|
||||
thumb_response = client.network.request(
|
||||
method="GET",
|
||||
endpoint=thumb_endpoint,
|
||||
raw_response=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
if thumb_response.status_code == 202:
|
||||
payload_202: Dict[str, Any] = {}
|
||||
try:
|
||||
payload_202 = thumb_response.json()
|
||||
except Exception:
|
||||
payload_202 = {"message": "Thumbnail is being generated"}
|
||||
return JSONResponse(status_code=202, content=payload_202)
|
||||
|
||||
content_type = thumb_response.headers.get("Content-Type", "image/png")
|
||||
return Response(content=thumb_response.content, media_type=content_type)
|
||||
except DashboardNotFoundError as e:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Dashboard not found for thumbnail: {e}")
|
||||
raise HTTPException(status_code=404, detail="Dashboard thumbnail not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard thumbnail: {str(e)}")
|
||||
# [/DEF:get_dashboard_thumbnail:Function]
|
||||
|
||||
# [DEF:MigrateRequest:DataClass]
|
||||
class MigrateRequest(BaseModel):
|
||||
source_env_id: str = Field(..., description="Source environment ID")
|
||||
|
||||
Reference in New Issue
Block a user