feat: LLM Validation section — 9 API endpoints + 3 Svelte pages + Playwright optimization

Backend:
- /api/validation/ CRUD for validation tasks (policies)
- Run history with 6 filters + pagination
- Alembic migrations (provider_id, policy_id)
- 28 pytest tests

Frontend:
- /validation — task list page with status filters + CRUD
- /validation/[id] — task config (Config + History tabs)
- /validation/history — run history with metrics
- Sidebar nav entry under Operations
- i18n en/ru

Playwright (ScreenshotService):
- Removed 25s hardcoded sleeps → conditional waits
- CDP fallback to full_page screenshot
- Total timeout enforcement (120s)
- Debug screenshots → temp dir with cleanup

QA fixes:
- Debug screenshot accumulation in production (tempdir + rmtree)
- Frontend API payload field name mismatch
- History issues field reference fix
- delete_runs scoped by policy_id

Belief protocol:
- @RATIONALE/@REJECTED on 27 C4+ contracts

Closes: VALIDATION-REWORK-2026
This commit is contained in:
2026-05-21 20:29:36 +03:00
parent 36643024cf
commit 48cc02ea0b
22 changed files with 3726 additions and 372 deletions

View File

@@ -13,6 +13,8 @@ import base64
import io
import json
import os
import shutil
import tempfile
from typing import Any
from urllib.parse import urlsplit
@@ -282,406 +284,508 @@ class ScreenshotService:
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
# endregion ScreenshotService._goto_resilient
# region ScreenshotService.capture_dashboard [TYPE Function]
# region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
# @BRIEF Wait until chart elements have non-zero dimensions, with polling.
# @PRE page is a valid Playwright page.
# @POST Waits for chart stabilization or raises on timeout (handled internally).
# @RATIONALE Polls for actual chart rendering dimensions rather than using a fixed delay — charts may load at different speeds depending on dashboard complexity and network conditions.
# @REJECTED Fixed sleep-based wait rejected — would either waste time (too long) or produce blank screenshots (too short); polling for actual canvas/svg dimensions is more reliable.
async def _wait_for_charts_stabilized(self, page, timeout_ms: int = 15000):
"""Wait until chart elements have non-zero dimensions, with polling."""
# Short initial delay for rendering pipeline to start
await asyncio.sleep(0.5)
try:
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
if (charts.length === 0) return true;
return Array.from(charts).some(c => {
if (c.tagName === 'CANVAS') return c.width > 10 && c.height > 10;
if (c.tagName === 'svg') {
const bbox = c.getBoundingClientRect();
return bbox.width > 10 && bbox.height > 10;
}
return false;
});
}""", timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Chart stabilization wait timed out, proceeding anyway")
# endregion ScreenshotService._wait_for_charts_stabilized
# region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
# @BRIEF Wait for charts to re-render after viewport resize.
# @PRE page is a valid Playwright page; chart_count_before contains pre-resize element counts.
# @POST Waits for chart content to return or timeout.
# @RATIONALE After viewport resize, Superset triggers lazy chart re-rendering — this function polls for chart elements to reappear before taking the screenshot.
# @REJECTED Single fixed wait after resize rejected — some dashboards re-render instantly while others take seconds; fixed wait is brittle across dashboard types.
async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
"""Wait for charts to re-render after viewport resize, with polling."""
try:
await page.wait_for_function("""(preCounts) => {
const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
const currentCanvases = document.querySelectorAll('canvas').length;
const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
// At least one chart element must be present
return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
}""", arg=chart_count_before, timeout=timeout_ms)
except Exception:
logger.warning("[ScreenshotService] Re-render wait timed out after viewport resize, proceeding anyway")
# endregion ScreenshotService._wait_for_resize_rendered
# region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
# @BRIEF Save a debug screenshot to a temp directory for diagnostic purposes.
# @PRE debug_dir exists and is writable.
# @POST Returns the debug path or None on failure.
# @RATIONALE Uses tempfile.mkdtemp() to avoid accumulating .png files in production storage. Temp dir is cleaned up on success (rmtree) or preserved on failure for debugging.
# @REJECTED Old approach saved debug .png next to output path — accumulated _debug_failed_login.png, _preresize.png permanently in screenshots dir (F1). In-memory-only debug logging rejected — screenshot state is visual and cannot be captured in logs.
async def _save_debug_screenshot(self, page, debug_dir: str, suffix: str) -> str | None:
debug_path = os.path.join(debug_dir, suffix)
try:
await page.screenshot(path=debug_path)
return debug_path
except Exception:
return None
# endregion ScreenshotService._save_debug_screenshot
# region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
# @PURPOSE Captures a full-page screenshot of a dashboard using Playwright and CDP.
# @PRE dashboard_id is a valid string, output_path is a writable path.
# @POST Returns True if screenshot is saved successfully.
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, and writes a PNG file.
# @POST Returns True if screenshot is saved successfully. Debug temp dir cleaned up on success, preserved on failure.
# @SIDE_EFFECT Launches a browser, performs UI login, switches tabs, writes a PNG file; creates and cleans up temp debug directory.
# @UX_STATE [Navigating] -> Loading dashboard UI
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
# @UX_STATE [Capturing] -> Executing CDP screenshot
# @RATIONALE Uses tempfile.mkdtemp() for all debug screenshots; cleans up on success (shutil.rmtree), preserves on failure for diagnostic analysis. Pre-resize screenshot is saved to temp dir and auto-cleaned on success.
# @REJECTED Old approach saved debug .png files alongside output — accumulated permanently (F1). In-memory-only logging rejected — visual state cannot be captured in text.
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox"
]
)
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Construct base UI URL from environment (strip /api/v1 suffix)
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[:-len("/api/v1")]
debug_dir = tempfile.mkdtemp(prefix="ss_screenshot_debug_")
success = False
try:
# -- begin original body --
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox"
]
)
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Construct base UI URL from environment (strip /api/v1 suffix)
base_ui_url = self.env.url.rstrip("/")
if base_ui_url.endswith("/api/v1"):
base_ui_url = base_ui_url[:-len("/api/v1")]
# Create browser context with realistic headers
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent=user_agent,
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1"
# Create browser context with realistic headers
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent=user_agent,
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1"
}
)
logger.info("Browser context created successfully")
page = await context.new_page()
# Bypass navigator.webdriver detection
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
response = await self._goto_resilient(
page,
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
# Wait for login form to be ready
await page.wait_for_load_state("domcontentloaded")
# More exhaustive list of selectors for various Superset versions/themes
selectors = {
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
}
)
logger.info("Browser context created successfully")
logger.info("[DEBUG] Attempting to find login form elements...")
page = await context.new_page()
# Bypass navigator.webdriver detection
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
try:
used_direct_form_login = False
# Find and fill username
username_locator = await self._find_login_field_locator(page, "username")
# 1. Navigate to login page and authenticate
login_url = f"{base_ui_url.rstrip('/')}/login/"
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
if not username_locator:
roots = self._iter_login_roots(page)
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
for root_index, root in enumerate(roots[:5]):
all_inputs = await root.locator('input').all()
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
for i, inp in enumerate(all_inputs[:5]): # Log first 5
inp_type = await inp.get_attribute('type')
inp_name = await inp.get_attribute('name')
inp_id = await inp.get_attribute('id')
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
username_locator = None
response = await self._goto_resilient(
page,
login_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Login page response status: {response.status}")
if username_locator is not None:
logger.info("[DEBUG] Filling username field")
await username_locator.fill(self.env.username)
# Wait for login form to be ready
await page.wait_for_load_state("domcontentloaded")
# Find and fill password
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
# More exhaustive list of selectors for various Superset versions/themes
selectors = {
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
}
logger.info("[DEBUG] Attempting to find login form elements...")
if username_locator is not None and not password_locator:
raise RuntimeError("Could not find password input field on login page")
try:
used_direct_form_login = False
# Find and fill username
username_locator = await self._find_login_field_locator(page, "username")
if password_locator is not None:
logger.info("[DEBUG] Filling password field")
await password_locator.fill(self.env.password)
if not username_locator:
roots = self._iter_login_roots(page)
logger.info(f"[DEBUG] Found {len(roots)} login roots including child frames")
for root_index, root in enumerate(roots[:5]):
all_inputs = await root.locator('input').all()
logger.info(f"[DEBUG] Root {root_index}: found {len(all_inputs)} input fields")
for i, inp in enumerate(all_inputs[:5]): # Log first 5
inp_type = await inp.get_attribute('type')
inp_name = await inp.get_attribute('name')
inp_id = await inp.get_attribute('id')
logger.info(f"[DEBUG] Root {root_index} input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
# Click submit
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
if username_locator is not None and not submit_locator:
raise RuntimeError("Could not find submit button on login page")
if submit_locator is not None:
logger.info("[DEBUG] Clicking submit button")
await submit_locator.click()
# Wait for navigation after login
if not used_direct_form_login:
raise RuntimeError("Could not find username input field on login page")
username_locator = None
try:
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
if username_locator is not None:
logger.info("[DEBUG] Filling username field")
await username_locator.fill(self.env.username)
# Check if login was successful
if not used_direct_form_login and "/login" in page.url:
# Check for error messages on page
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
# Find and fill password
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
if username_locator is not None and not password_locator:
raise RuntimeError("Could not find password input field on login page")
# Check cookies after successful login
page_cookies = await context.cookies()
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
for c in page_cookies:
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
if password_locator is not None:
logger.info("[DEBUG] Filling password field")
await password_locator.fill(self.env.password)
except Exception as e:
page_title = await page.title()
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_login.png")
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# Click submit
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
# 2. Navigate to dashboard
# @UX_STATE [Navigating] -> Loading dashboard UI
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
if username_locator is not None and not submit_locator:
raise RuntimeError("Could not find submit button on login page")
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
dashboard_url = dashboard_url.replace("http://", "https://")
if submit_locator is not None:
logger.info("[DEBUG] Clicking submit button")
await submit_locator.click()
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
# Wait for navigation after login
if not used_direct_form_login:
try:
await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
except Exception as load_wait_error:
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
# Check if login was successful
if not used_direct_form_login and "/login" in page.url:
# Check for error messages on page
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
# Check cookies after successful login
page_cookies = await context.cookies()
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
for c in page_cookies:
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
except Exception as e:
page_title = await page.title()
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
debug_path = output_path.replace(".png", "_debug_failed_login.png")
await page.screenshot(path=debug_path)
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
# 2. Navigate to dashboard
# @UX_STATE [Navigating] -> Loading dashboard UI
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
dashboard_url = dashboard_url.replace("http://", "https://")
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
# Dashboard pages can keep polling/network activity open indefinitely.
response = await self._goto_resilient(
page,
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
if response:
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
if "/login" in page.url:
debug_path = output_path.replace(".png", "_debug_failed_dashboard_auth.png")
await page.screenshot(path=debug_path)
raise RuntimeError(
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
# Dashboard pages can keep polling/network activity open indefinitely.
response = await self._goto_resilient(
page,
dashboard_url,
primary_wait_until="domcontentloaded",
fallback_wait_until="load",
timeout=HTTP_REQUEST_TIMEOUT_MS,
)
try:
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
logger.info("[DEBUG] Dashboard container loaded")
if response:
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
if "/login" in page.url:
debug_path = await self._save_debug_screenshot(page, debug_dir, "failed_dashboard_auth.png")
raise RuntimeError(
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
)
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
# We wait until loading indicators disappear or a timeout occurs
try:
# Wait for loading indicators to disappear
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Loading indicators hidden")
except Exception:
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
# Wait for the dashboard grid to be present
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
logger.info("[DEBUG] Dashboard container loaded")
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
# We look for common chart containers that should have content
try:
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Chart content detected")
except Exception:
logger.warning("[DEBUG] Timeout waiting for chart content")
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
# We wait until loading indicators disappear or a timeout occurs
try:
# Wait for loading indicators to disappear
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Loading indicators hidden")
except Exception:
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
# Additional check: wait for all chart containers to have non-empty content
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container, .slice_container');
if (charts.length === 0) return true; // No charts to wait for
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
# We look for common chart containers that should have content
try:
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] Chart content detected")
except Exception:
logger.warning("[DEBUG] Timeout waiting for chart content")
# Additional check: wait for all chart containers to have non-empty content
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
await page.wait_for_function("""() => {
const charts = document.querySelectorAll('.chart-container, .slice_container');
if (charts.length === 0) return true; // No charts to wait for
// Check if all charts have rendered content (canvas, svg, or non-empty div)
return Array.from(charts).every(chart => {
const hasCanvas = chart.querySelector('canvas') !== null;
const hasSvg = chart.querySelector('svg') !== null;
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] All charts have rendered content")
// Check if all charts have rendered content (canvas, svg, or non-empty div)
return Array.from(charts).every(chart => {
const hasCanvas = chart.querySelector('canvas') !== null;
const hasSvg = chart.querySelector('svg') !== null;
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
return hasCanvas || hasSvg || hasContent;
});
}""", timeout=HTTP_REQUEST_TIMEOUT_MS)
logger.info("[DEBUG] All charts have rendered content")
# Scroll to bottom and back to top to trigger lazy loading of all charts
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
await page.evaluate("""async () => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < document.body.scrollHeight; i += 500) {
window.scrollTo(0, i);
await delay(100);
}
window.scrollTo(0, 0);
await delay(500);
}""")
except Exception as e:
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
# Final stabilization delay - increased for complex dashboards
logger.info("[DEBUG] Final stabilization delay...")
await asyncio.sleep(15)
# Logic to handle tabs and full-page capture
try:
# 1. Handle Tabs (Recursive switching)
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
processed_tabs = set()
async def switch_tabs(depth=0):
if depth > 3:
return # Limit recursion depth
tab_selectors = [
'.ant-tabs-nav-list .ant-tabs-tab',
'.dashboard-component-tabs .ant-tabs-tab',
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
]
found_tabs = []
for selector in tab_selectors:
found_tabs = await page.locator(selector).all()
if found_tabs:
break
if found_tabs:
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
for i, tab in enumerate(found_tabs):
try:
tab_text = (await tab.inner_text()).strip()
tab_id = f"{depth}_{i}_{tab_text}"
if tab_id in processed_tabs:
continue
if await tab.is_visible():
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
processed_tabs.add(tab_id)
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
await asyncio.sleep(2) # Wait for content to render
await switch_tabs(depth + 1)
except Exception as tab_e:
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
await first_tab.click()
await asyncio.sleep(1)
except Exception:
pass
await switch_tabs()
# 2. Calculate full height for screenshot
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
full_height = await page.evaluate("""() => {
const body = document.body;
const html = document.documentElement;
const dashboardContent = document.querySelector('.dashboard-content');
return Math.max(
body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight,
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
);
}""")
logger.info(f"[DEBUG] Calculated full height: {full_height}")
# DIAGNOSTIC: Count chart elements before resize
chart_count_before = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = output_path.replace(".png", "_preresize.png")
try:
await page.screenshot(path=pre_resize_path, full_page=False, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
import os
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
except Exception as pre_e:
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
# DIAGNOSTIC: Increased wait time and log timing
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
await asyncio.sleep(10)
logger.info("[DIAGNOSTIC] Wait completed")
# DIAGNOSTIC: Count chart elements after resize and wait
chart_count_after = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
# DIAGNOSTIC: Check if any charts have error states
chart_errors = await page.evaluate("""() => {
const errors = [];
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
if (errorEl) {
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
# Scroll to bottom and back to top to trigger lazy loading of all charts
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
await page.evaluate("""async () => {
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < document.body.scrollHeight; i += 500) {
window.scrollTo(0, i);
await delay(100);
}
});
return errors;
}""")
if chart_errors:
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
else:
logger.info("[DIAGNOSTIC] No chart error states detected")
window.scrollTo(0, 0);
await delay(500);
}""")
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
# @UX_STATE [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp = await page.context.new_cdp_session(page)
except Exception as e:
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
screenshot_data = await cdp.send("Page.captureScreenshot", {
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True
})
# Final stabilization delay - wait for charts to stabilize
logger.info("[DEBUG] Waiting for charts to stabilize...")
await self._wait_for_charts_stabilized(page)
image_data = base64.b64decode(screenshot_data["data"])
with open(output_path, 'wb') as f:
f.write(image_data)
# DIAGNOSTIC: Verify screenshot file
import os
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
# DIAGNOSTIC: Get image dimensions
# Logic to handle tabs and full-page capture
try:
with Image.open(output_path) as final_img:
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
except Exception as img_err:
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
async with asyncio.timeout(SCREENSHOT_SERVICE_TIMEOUT_MS / 1000):
# 1. Handle Tabs (Recursive switching)
# @UX_STATE [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
processed_tabs = set()
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
try:
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
async def switch_tabs(depth=0):
if depth > 3:
return # Limit recursion depth
await browser.close()
return True
tab_selectors = [
'.ant-tabs-nav-list .ant-tabs-tab',
'.dashboard-component-tabs .ant-tabs-tab',
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
]
found_tabs = []
for selector in tab_selectors:
found_tabs = await page.locator(selector).all()
if found_tabs:
break
if found_tabs:
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
for i, tab in enumerate(found_tabs):
try:
tab_text = (await tab.inner_text()).strip()
tab_id = f"{depth}_{i}_{tab_text}"
if tab_id in processed_tabs:
continue
if await tab.is_visible():
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
processed_tabs.add(tab_id)
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
if not is_active:
await tab.click()
# Wait for chart content to render in the newly active tab
try:
await page.wait_for_function("""() => {
const activeTab = document.querySelector('.ant-tabs-tab-active');
if (!activeTab) return true;
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
if (!tabPane) return true;
const charts = tabPane.querySelectorAll('canvas, svg');
return charts.length > 0;
}""", timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception:
logger.warning(f"[TabSwitching] Content verification timed out for tab: {tab_text}")
await switch_tabs(depth + 1)
except Exception as tab_e:
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
try:
first_tab = found_tabs[0]
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
await first_tab.click()
try:
await page.wait_for_function("""() => {
const activeTab = document.querySelector('.ant-tabs-tab-active');
if (!activeTab) return true;
const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
if (!tabPane) return true;
const charts = tabPane.querySelectorAll('canvas, svg');
return charts.length > 0;
}""", timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
except Exception:
logger.warning("[TabSwitching] Content verification timed out for first tab")
except Exception:
pass
await switch_tabs()
# 2. Calculate full height for screenshot
# @UX_STATE [CalculatingHeight] -> Determining dashboard dimensions
full_height = await page.evaluate("""() => {
const body = document.body;
const html = document.documentElement;
const dashboardContent = document.querySelector('.dashboard-content');
return Math.max(
body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight,
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
);
}""")
logger.info(f"[DEBUG] Calculated full height: {full_height}")
# DIAGNOSTIC: Count chart elements before resize
chart_count_before = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
pre_resize_path = await self._save_debug_screenshot(page, debug_dir, "preresize.png")
if pre_resize_path:
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
else:
logger.warning("[DIAGNOSTIC] Failed to capture pre-resize screenshot")
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
# Wait for charts to re-render after viewport resize
logger.info("[DIAGNOSTIC] Waiting for re-render after viewport resize...")
await self._wait_for_resize_rendered(page, chart_count_before)
logger.info("[DIAGNOSTIC] Re-render wait completed")
# DIAGNOSTIC: Count chart elements after resize and wait
chart_count_after = await page.evaluate("""() => {
return {
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
canvasElements: document.querySelectorAll('canvas').length,
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
};
}""")
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
# DIAGNOSTIC: Check if any charts have error states
chart_errors = await page.evaluate("""() => {
const errors = [];
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
if (errorEl) {
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
}
});
return errors;
}""")
if chart_errors:
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
else:
logger.info("[DIAGNOSTIC] No chart error states detected")
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
# @UX_STATE [Capturing] -> Executing CDP screenshot
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
cdp_success = False
try:
cdp = await page.context.new_cdp_session(page)
screenshot_data = await cdp.send("Page.captureScreenshot", {
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True
})
image_data = base64.b64decode(screenshot_data["data"])
with open(output_path, 'wb') as f:
f.write(image_data)
logger.info("Full-page screenshot saved via CDP")
cdp_success = True
except Exception as cdp_err:
logger.warning(f"CDP screenshot failed: {cdp_err}. Falling back to Playwright full_page.")
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
logger.info("Full-page screenshot saved via Playwright fallback")
cdp_success = True
if cdp_success:
# DIAGNOSTIC: Verify screenshot file
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
# DIAGNOSTIC: Get image dimensions
try:
with Image.open(output_path) as final_img:
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
except Exception as img_err:
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
except asyncio.TimeoutError:
logger.error(f"Screenshot capture timed out after {SCREENSHOT_SERVICE_TIMEOUT_MS}ms")
try:
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
logger.info("Emergency timeout screenshot saved")
except Exception:
pass
except Exception as e:
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
try:
await page.screenshot(path=output_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)
except Exception as e2:
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS)
await browser.close()
success = True
return True
finally:
if success:
shutil.rmtree(debug_dir, ignore_errors=True)
else:
logger.info(f"Debug screenshots preserved in {debug_dir}")
# endregion ScreenshotService.capture_dashboard
# #endregion ScreenshotService
@@ -985,18 +1089,7 @@ class LLMClient:
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
}
# endregion LLMClient.analyze_dashboard
# #endregion LLMClient
# #endregion LLMAnalysisService
return await self.get_json_completion(messages)
except Exception as e:
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
return {
"status": "UNKNOWN",
"summary": f"Failed to get response from LLM: {e!s}",
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
}
# endregion LLMClient.analyze_dashboard
# #endregion LLMClient
# #endregion LLMAnalysisService