Fix LLM validation and dashboard health hot paths
This commit is contained in:
@@ -12,6 +12,8 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
from typing import List, Dict, Any
|
||||
import httpx
|
||||
from PIL import Image
|
||||
@@ -33,6 +35,242 @@ class ScreenshotService:
|
||||
self.env = env
|
||||
# [/DEF:ScreenshotService.__init__:Function]
|
||||
|
||||
# [DEF:ScreenshotService._find_first_visible_locator:Function]
|
||||
# @PURPOSE: Resolve the first visible locator from multiple Playwright locator strategies.
|
||||
# @PRE: candidates is a non-empty list of locator-like objects.
|
||||
# @POST: Returns a locator ready for interaction or None when nothing matches.
|
||||
async def _find_first_visible_locator(self, candidates) -> Any:
|
||||
for locator in candidates:
|
||||
try:
|
||||
match_count = await locator.count()
|
||||
for index in range(match_count):
|
||||
candidate = locator.nth(index)
|
||||
if await candidate.is_visible():
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
# [/DEF:ScreenshotService._find_first_visible_locator:Function]
|
||||
|
||||
# [DEF:ScreenshotService._iter_login_roots:Function]
|
||||
# @PURPOSE: Enumerate page and child frames where login controls may be rendered.
|
||||
# @PRE: page is a Playwright page-like object.
|
||||
# @POST: Returns ordered roots starting with main page followed by frames.
|
||||
def _iter_login_roots(self, page) -> List[Any]:
|
||||
roots = [page]
|
||||
page_frames = getattr(page, "frames", [])
|
||||
try:
|
||||
for frame in page_frames:
|
||||
if frame not in roots:
|
||||
roots.append(frame)
|
||||
except Exception:
|
||||
pass
|
||||
return roots
|
||||
# [/DEF:ScreenshotService._iter_login_roots:Function]
|
||||
|
||||
# [DEF:ScreenshotService._extract_hidden_login_fields:Function]
|
||||
# @PURPOSE: Collect hidden form fields required for direct login POST fallback.
|
||||
# @PRE: Login page is loaded.
|
||||
# @POST: Returns hidden input name/value mapping aggregated from page and child frames.
|
||||
async def _extract_hidden_login_fields(self, page) -> Dict[str, str]:
|
||||
hidden_fields: Dict[str, str] = {}
|
||||
for root in self._iter_login_roots(page):
|
||||
try:
|
||||
locator = root.locator("input[type='hidden'][name]")
|
||||
count = await locator.count()
|
||||
for index in range(count):
|
||||
candidate = locator.nth(index)
|
||||
field_name = str(await candidate.get_attribute("name") or "").strip()
|
||||
if not field_name or field_name in hidden_fields:
|
||||
continue
|
||||
hidden_fields[field_name] = str(await candidate.input_value()).strip()
|
||||
except Exception:
|
||||
continue
|
||||
return hidden_fields
|
||||
# [/DEF:ScreenshotService._extract_hidden_login_fields:Function]
|
||||
|
||||
# [DEF:ScreenshotService._extract_csrf_token:Function]
|
||||
# @PURPOSE: Resolve CSRF token value from main page or embedded login frame.
|
||||
# @PRE: Login page is loaded.
|
||||
# @POST: Returns first non-empty csrf token or empty string.
|
||||
async def _extract_csrf_token(self, page) -> str:
|
||||
hidden_fields = await self._extract_hidden_login_fields(page)
|
||||
return str(hidden_fields.get("csrf_token") or "").strip()
|
||||
# [/DEF:ScreenshotService._extract_csrf_token:Function]
|
||||
|
||||
# [DEF:ScreenshotService._response_looks_like_login_page:Function]
|
||||
# @PURPOSE: Detect when fallback login POST returned the login form again instead of an authenticated page.
|
||||
# @PRE: response_text is normalized HTML or text from login POST response.
|
||||
# @POST: Returns True when login-page markers dominate the response body.
|
||||
def _response_looks_like_login_page(self, response_text: str) -> bool:
|
||||
normalized = str(response_text or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
markers = [
|
||||
"enter your login and password below",
|
||||
"username:",
|
||||
"password:",
|
||||
"sign in",
|
||||
'name="csrf_token"',
|
||||
]
|
||||
return sum(marker in normalized for marker in markers) >= 3
|
||||
# [/DEF:ScreenshotService._response_looks_like_login_page:Function]
|
||||
|
||||
# [DEF:ScreenshotService._redirect_looks_authenticated:Function]
|
||||
# @PURPOSE: Treat non-login redirects after form POST as successful authentication without waiting for redirect target.
|
||||
# @PRE: redirect_location may be empty or relative.
|
||||
# @POST: Returns True when redirect target does not point back to login flow.
|
||||
def _redirect_looks_authenticated(self, redirect_location: str) -> bool:
|
||||
normalized = str(redirect_location or "").strip().lower()
|
||||
if not normalized:
|
||||
return True
|
||||
return "/login" not in normalized
|
||||
# [/DEF:ScreenshotService._redirect_looks_authenticated:Function]
|
||||
|
||||
# [DEF:ScreenshotService._submit_login_via_form_post:Function]
|
||||
# @PURPOSE: Fallback login path that submits credentials directly with csrf token.
|
||||
# @PRE: login_url is same-origin and csrf token can be read from DOM.
|
||||
# @POST: Browser context receives authenticated cookies when login succeeds.
|
||||
async def _submit_login_via_form_post(self, page, login_url: str) -> bool:
|
||||
hidden_fields = await self._extract_hidden_login_fields(page)
|
||||
csrf_token = str(hidden_fields.get("csrf_token") or "").strip()
|
||||
if not csrf_token:
|
||||
logger.warning("[DEBUG] Direct form login fallback skipped: csrf_token not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
request_context = page.context.request
|
||||
except Exception as context_error:
|
||||
logger.warning(f"[DEBUG] Direct form login fallback skipped: request context unavailable: {context_error}")
|
||||
return False
|
||||
|
||||
parsed_url = urlsplit(login_url)
|
||||
origin = f"{parsed_url.scheme}://{parsed_url.netloc}" if parsed_url.scheme and parsed_url.netloc else login_url
|
||||
payload = dict(hidden_fields)
|
||||
payload["username"] = self.env.username
|
||||
payload["password"] = self.env.password
|
||||
logger.info(
|
||||
f"[DEBUG] Attempting direct form login fallback via browser context request with hidden fields: {sorted(hidden_fields.keys())}"
|
||||
)
|
||||
|
||||
response = await request_context.post(
|
||||
login_url,
|
||||
form=payload,
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Referer": login_url,
|
||||
},
|
||||
timeout=10000,
|
||||
fail_on_status_code=False,
|
||||
max_redirects=0,
|
||||
)
|
||||
response_url = str(getattr(response, "url", "") or "")
|
||||
response_status = int(getattr(response, "status", 0) or 0)
|
||||
response_headers = dict(getattr(response, "headers", {}) or {})
|
||||
redirect_location = str(
|
||||
response_headers.get("location")
|
||||
or response_headers.get("Location")
|
||||
or ""
|
||||
).strip()
|
||||
redirect_statuses = {301, 302, 303, 307, 308}
|
||||
if response_status in redirect_statuses:
|
||||
redirect_authenticated = self._redirect_looks_authenticated(redirect_location)
|
||||
logger.info(
|
||||
f"[DEBUG] Direct form login fallback redirect response: status={response_status} url={response_url} location={redirect_location!r} authenticated={redirect_authenticated}"
|
||||
)
|
||||
return redirect_authenticated
|
||||
|
||||
response_text = await response.text()
|
||||
text_snippet = " ".join(response_text.split())[:200]
|
||||
looks_like_login_page = self._response_looks_like_login_page(response_text)
|
||||
logger.info(
|
||||
f"[DEBUG] Direct form login fallback response: status={response_status} url={response_url} login_markup={looks_like_login_page} snippet={text_snippet!r}"
|
||||
)
|
||||
return not looks_like_login_page
|
||||
# [/DEF:ScreenshotService._submit_login_via_form_post:Function]
|
||||
|
||||
# [DEF:ScreenshotService._find_login_field_locator:Function]
|
||||
# @PURPOSE: Resolve login form input using semantic label text plus generic visible-input fallbacks.
|
||||
# @PRE: field_name is `username` or `password`.
|
||||
# @POST: Returns a locator for the corresponding input or None.
|
||||
async def _find_login_field_locator(self, page, field_name: str) -> Any:
|
||||
normalized = str(field_name or "").strip().lower()
|
||||
for root in self._iter_login_roots(page):
|
||||
if normalized == "username":
|
||||
input_candidates = [
|
||||
root.get_by_label("Username", exact=False),
|
||||
root.get_by_label("Login", exact=False),
|
||||
root.locator("label:text-matches('Username|Login', 'i')").locator("xpath=following::input[1]"),
|
||||
root.locator("text=/Username|Login/i").locator("xpath=following::input[1]"),
|
||||
root.locator("input[name='username']"),
|
||||
root.locator("input#username"),
|
||||
root.locator("input[placeholder*='Username']"),
|
||||
root.locator("input[type='text']"),
|
||||
root.locator("input:not([type='password'])"),
|
||||
]
|
||||
locator = await self._find_first_visible_locator(input_candidates)
|
||||
if locator:
|
||||
return locator
|
||||
|
||||
if normalized == "password":
|
||||
input_candidates = [
|
||||
root.get_by_label("Password", exact=False),
|
||||
root.locator("label:text-matches('Password', 'i')").locator("xpath=following::input[1]"),
|
||||
root.locator("text=/Password/i").locator("xpath=following::input[1]"),
|
||||
root.locator("input[name='password']"),
|
||||
root.locator("input#password"),
|
||||
root.locator("input[placeholder*='Password']"),
|
||||
root.locator("input[type='password']"),
|
||||
]
|
||||
locator = await self._find_first_visible_locator(input_candidates)
|
||||
if locator:
|
||||
return locator
|
||||
|
||||
return None
|
||||
# [/DEF:ScreenshotService._find_login_field_locator:Function]
|
||||
|
||||
# [DEF:ScreenshotService._find_submit_locator:Function]
|
||||
# @PURPOSE: Resolve login submit button from main page or embedded auth frame.
|
||||
# @PRE: page is ready for login interaction.
|
||||
# @POST: Returns visible submit locator or None.
|
||||
async def _find_submit_locator(self, page) -> Any:
|
||||
selectors = [
|
||||
lambda root: root.get_by_role("button", name="Sign in", exact=False),
|
||||
lambda root: root.get_by_role("button", name="Login", exact=False),
|
||||
lambda root: root.locator("button[type='submit']"),
|
||||
lambda root: root.locator("button#submit"),
|
||||
lambda root: root.locator(".btn-primary"),
|
||||
lambda root: root.locator("input[type='submit']"),
|
||||
]
|
||||
for root in self._iter_login_roots(page):
|
||||
locator = await self._find_first_visible_locator([factory(root) for factory in selectors])
|
||||
if locator:
|
||||
return locator
|
||||
return None
|
||||
# [/DEF:ScreenshotService._find_submit_locator:Function]
|
||||
|
||||
# [DEF:ScreenshotService._goto_resilient:Function]
|
||||
# @PURPOSE: Navigate without relying on networkidle for pages with long-polling or persistent requests.
|
||||
# @PRE: page is a valid Playwright page and url is non-empty.
|
||||
# @POST: Returns last navigation response or raises when both primary and fallback waits fail.
|
||||
async def _goto_resilient(
|
||||
self,
|
||||
page,
|
||||
url: str,
|
||||
primary_wait_until: str = "domcontentloaded",
|
||||
fallback_wait_until: str = "load",
|
||||
timeout: int = 60000,
|
||||
):
|
||||
try:
|
||||
return await page.goto(url, wait_until=primary_wait_until, timeout=timeout)
|
||||
except Exception as primary_error:
|
||||
logger.warning(
|
||||
f"[ScreenshotService._goto_resilient] Primary navigation wait '{primary_wait_until}' failed for {url}: {primary_error}"
|
||||
)
|
||||
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
|
||||
# [/DEF:ScreenshotService._goto_resilient:Function]
|
||||
|
||||
# [DEF:ScreenshotService.capture_dashboard:Function]
|
||||
# @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.
|
||||
@@ -85,7 +323,13 @@ class ScreenshotService:
|
||||
login_url = f"{base_ui_url.rstrip('/')}/login/"
|
||||
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
|
||||
|
||||
response = await page.goto(login_url, wait_until="networkidle", timeout=60000)
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
login_url,
|
||||
primary_wait_until="domcontentloaded",
|
||||
fallback_wait_until="load",
|
||||
timeout=60000,
|
||||
)
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Login page response status: {response.status}")
|
||||
|
||||
@@ -101,57 +345,59 @@ class ScreenshotService:
|
||||
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||
|
||||
try:
|
||||
used_direct_form_login = False
|
||||
# Find and fill username
|
||||
u_selector = None
|
||||
for s in selectors["username"]:
|
||||
count = await page.locator(s).count()
|
||||
logger.info(f"[DEBUG] Selector '{s}': {count} elements found")
|
||||
if count > 0:
|
||||
u_selector = s
|
||||
break
|
||||
username_locator = await self._find_login_field_locator(page, "username")
|
||||
|
||||
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
|
||||
|
||||
if not u_selector:
|
||||
# Log all input fields on the page for debugging
|
||||
all_inputs = await page.locator('input').all()
|
||||
logger.info(f"[DEBUG] Found {len(all_inputs)} input fields on page")
|
||||
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] Input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
|
||||
raise RuntimeError("Could not find username input field on login page")
|
||||
|
||||
logger.info(f"[DEBUG] Filling username field with selector: {u_selector}")
|
||||
await page.fill(u_selector, self.env.username)
|
||||
if username_locator is not None:
|
||||
logger.info("[DEBUG] Filling username field")
|
||||
await username_locator.fill(self.env.username)
|
||||
|
||||
# Find and fill password
|
||||
p_selector = None
|
||||
for s in selectors["password"]:
|
||||
if await page.locator(s).count() > 0:
|
||||
p_selector = s
|
||||
break
|
||||
|
||||
if not p_selector:
|
||||
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
|
||||
|
||||
if username_locator is not None and not password_locator:
|
||||
raise RuntimeError("Could not find password input field on login page")
|
||||
|
||||
logger.info(f"[DEBUG] Filling password field with selector: {p_selector}")
|
||||
await page.fill(p_selector, self.env.password)
|
||||
if password_locator is not None:
|
||||
logger.info("[DEBUG] Filling password field")
|
||||
await password_locator.fill(self.env.password)
|
||||
|
||||
# Click submit
|
||||
s_selector = selectors["submit"][0]
|
||||
for s in selectors["submit"]:
|
||||
if await page.locator(s).count() > 0:
|
||||
s_selector = s
|
||||
break
|
||||
|
||||
logger.info(f"[DEBUG] Clicking submit button with selector: {s_selector}")
|
||||
await page.click(s_selector)
|
||||
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
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
if not used_direct_form_login:
|
||||
try:
|
||||
await page.wait_for_load_state("load", timeout=30000)
|
||||
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 "/login" in page.url:
|
||||
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}")
|
||||
@@ -183,11 +429,24 @@ class ScreenshotService:
|
||||
|
||||
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
|
||||
|
||||
# Use networkidle to ensure all initial assets are loaded
|
||||
response = await page.goto(dashboard_url, wait_until="networkidle", timeout=60000)
|
||||
# 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=60000,
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for the dashboard grid to be present
|
||||
@@ -440,6 +699,13 @@ class LLMClient:
|
||||
|
||||
# Some OpenAI-compatible gateways are strict about auth header naming.
|
||||
default_headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if self.provider_type == LLMProviderType.OPENROUTER:
|
||||
default_headers["HTTP-Referer"] = (
|
||||
os.getenv("OPENROUTER_SITE_URL", "").strip()
|
||||
or os.getenv("APP_BASE_URL", "").strip()
|
||||
or "http://localhost:8000"
|
||||
)
|
||||
default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "ss-tools"
|
||||
if self.provider_type == LLMProviderType.KILO:
|
||||
default_headers["Authentication"] = f"Bearer {self.api_key}"
|
||||
default_headers["X-API-Key"] = self.api_key
|
||||
@@ -595,6 +861,22 @@ class LLMClient:
|
||||
raise
|
||||
# [/DEF:LLMClient.get_json_completion:Function]
|
||||
|
||||
# [DEF:LLMClient.test_runtime_connection:Function]
|
||||
# @PURPOSE: Validate provider credentials using the same chat completions transport as runtime analysis.
|
||||
# @PRE: Client is initialized with provider credentials and default_model.
|
||||
# @POST: Returns lightweight JSON payload when runtime auth/model path is valid.
|
||||
# @SIDE_EFFECT: Calls external LLM API.
|
||||
async def test_runtime_connection(self) -> Dict[str, Any]:
|
||||
with belief_scope("test_runtime_connection"):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": 'Return exactly this JSON object and nothing else: {"ok": true}',
|
||||
}
|
||||
]
|
||||
return await self.get_json_completion(messages)
|
||||
# [/DEF:LLMClient.test_runtime_connection:Function]
|
||||
|
||||
# [DEF:LLMClient.analyze_dashboard:Function]
|
||||
# @PURPOSE: Sends dashboard data (screenshot + logs) to LLM for health analysis.
|
||||
# @PRE: screenshot_path exists, logs is a list of strings.
|
||||
@@ -661,9 +943,9 @@ class LLMClient:
|
||||
except Exception as e:
|
||||
logger.error(f"[analyze_dashboard] Failed to get analysis: {str(e)}")
|
||||
return {
|
||||
"status": "FAIL",
|
||||
"status": "UNKNOWN",
|
||||
"summary": f"Failed to get response from LLM: {str(e)}",
|
||||
"issues": [{"severity": "FAIL", "message": "LLM provider returned empty or invalid response"}]
|
||||
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
# [/DEF:LLMClient.analyze_dashboard:Function]
|
||||
# [/DEF:LLMClient:Class]
|
||||
|
||||
Reference in New Issue
Block a user