From 112ab552e99f09df0cc9656ffb9ff3082517daa0 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sat, 5 Sep 2026 22:45:12 +0200 Subject: [PATCH] deploy Nous v22 update --- .env.example | 3 +- README.md | 16 +++- api/browser_investigator.py | 26 ++++-- api/intelligence.py | 25 ++++-- api/server.py | 173 ++++++++++++++++++++++++++++++------ docker-compose.yml | 3 +- tests/test_app.py | 19 ++++ web/app.js | 48 +++++++++- web/index.html | 2 +- web/style.css | 2 +- 10 files changed, 265 insertions(+), 52 deletions(-) diff --git a/.env.example b/.env.example index 7ce8515..b6f37c6 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,8 @@ OUTBOUND_USER_AGENT=ProspectOS/2.0 (+personal-use) # solve CAPTCHAs, or bypass access challenges. BROWSER_USE_ENABLED=true BROWSER_USE_MAX_STEPS=10 -BROWSER_USE_TIMEOUT_SECONDS=120 +BROWSER_USE_TIMEOUT_SECONDS=600 +BROWSER_USE_LLM_TIMEOUT_SECONDS=90 ANONYMIZED_TELEMETRY=false LOG_RETENTION=20000 diff --git a/README.md b/README.md index 3e24d33..ea5af14 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ error rather than remaining stuck forever. | `OUTBOUND_USER_AGENT` | `ProspectOS/2.0 (+personal-use)` | Identifies provider HTTP requests; avoids Python's default client fingerprint | | `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback | | `BROWSER_USE_ENABLED` | `true` | Enables the optional website-candidate investigator | -| `BROWSER_USE_MAX_STEPS` | `10` | Maximum Browser Use actions for one investigation (3–15) | -| `BROWSER_USE_TIMEOUT_SECONDS` | `120` | Hard timeout for one investigation (30–300 seconds) | +| `BROWSER_USE_MAX_STEPS` | `10` | Maximum Browser Use actions for one investigation (3–50); can be changed in Settings | +| `BROWSER_USE_TIMEOUT_SECONDS` | `600` | Hard timeout for one investigation (30–1800 seconds); can be changed in Settings | +| `BROWSER_USE_LLM_TIMEOUT_SECONDS` | `90` | Timeout for one Browser Use model call (10–300 seconds); can be changed in Settings | +| `WEBSITE_SCAN_TIMEOUT` | `8` | Per-page public website/email scan timeout (2–60 seconds); can be changed in Settings | | `ANONYMIZED_TELEMETRY` | `false` | Keeps Browser Use telemetry disabled in this personal deployment | | `LOG_RETENTION` | `20000` | Maximum operational log entries retained in SQLite | | `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint | @@ -129,6 +131,16 @@ The API equivalents are `POST /api/prospects/{id}/website-candidates`, `PATCH /api/prospects/{id}/website-candidates/{candidate_id}` with `{"status":"accepted"}` or `{"status":"rejected"}`. +### Runtime settings + +Open **Settings → Browser and discovery timing** to change Browser Use's +enabled state, total investigation timeout, maximum agent steps, and per-call +AI timeout. The same panel controls the Maps worker wait timeout/poll interval, +search depth, and website health/email scan timeout. Values are stored in the +local SQLite database, survive container restarts, and override `.env` defaults +for new runs. The safe UI ranges prevent a malformed value from creating an +unbounded worker. + The app stores its catalogue in the `prospect_data` Docker volume. The worker stores its own job data in `gmaps_data`; keep both volumes if you want to retain history. To inspect status: diff --git a/api/browser_investigator.py b/api/browser_investigator.py index b5efbf0..f4cba9b 100644 --- a/api/browser_investigator.py +++ b/api/browser_investigator.py @@ -98,9 +98,15 @@ def investigate_websites( model: str, user_agent: str, log_callback: Callable[[str, str, dict[str, Any]], Any] | None = None, + enabled: bool | None = None, + max_steps: int | None = None, + timeout_seconds: int | None = None, + llm_timeout_seconds: int | None = None, ) -> list[dict[str, Any]]: """Run one bounded Browser Use investigation and return candidates.""" - if os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() in {"0", "false", "no", "off"}: + if enabled is None: + enabled = os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"} + if not enabled: raise RuntimeError("browser_use_disabled") if not base_url or not model: raise RuntimeError("browser_use_requires_ai_provider_and_model") @@ -189,6 +195,10 @@ Confidence must be an integer from 0 to 100. Include only plausible official web enable_default_extensions=False, captcha_solver=False, ) + try: + llm_timeout = max(10, min(300, int(llm_timeout_seconds if llm_timeout_seconds is not None else os.environ.get("BROWSER_USE_LLM_TIMEOUT_SECONDS", "90")))) + except (TypeError, ValueError): + llm_timeout = 45 llm = LoggedChatOpenAI( model=model, api_key=api_key, @@ -198,7 +208,7 @@ Confidence must be an integer from 0 to 100. Include only plausible official web frequency_penalty=None, max_completion_tokens=900, max_retries=1, - timeout=45, + timeout=llm_timeout, ) agent = Agent( task=task, @@ -212,16 +222,16 @@ Confidence must be an integer from 0 to 100. Include only plausible official web register_new_step_callback=on_step, ) try: - max_steps = int(os.environ.get("BROWSER_USE_MAX_STEPS", "10")) + resolved_steps = int(max_steps if max_steps is not None else os.environ.get("BROWSER_USE_MAX_STEPS", "10")) except (TypeError, ValueError): - max_steps = 10 - return await agent.run(max_steps=max(3, min(15, max_steps))) + resolved_steps = 10 + return await agent.run(max_steps=max(3, min(50, resolved_steps))) try: - timeout = int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120")) + timeout = int(timeout_seconds if timeout_seconds is not None else os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "600")) except (TypeError, ValueError): - timeout = 120 - timeout = max(30, min(300, timeout)) + timeout = 600 + timeout = max(30, min(1800, timeout)) try: history = asyncio.run(asyncio.wait_for(run(), timeout=timeout)) except asyncio.TimeoutError as exc: diff --git a/api/intelligence.py b/api/intelligence.py index f31de78..909ec1a 100644 --- a/api/intelligence.py +++ b/api/intelligence.py @@ -24,7 +24,10 @@ EMAIL_RE = re.compile(r"[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,63}", re.I) MAX_BODY_BYTES = 700_000 MAX_REDIRECTS = 2 MAX_PAGES = 2 -TIMEOUT_SECONDS = max(2, min(30, int(os.environ.get("WEBSITE_SCAN_TIMEOUT", "8")))) +try: + TIMEOUT_SECONDS = max(2, min(60, int(os.environ.get("WEBSITE_SCAN_TIMEOUT", "8")))) +except (TypeError, ValueError): + TIMEOUT_SECONDS = 8 # getaddrinfo has no per-call timeout parameter on all supported platforms. # Set a bounded process default so a broken DNS server cannot hold a discovery # worker forever during website analysis. @@ -105,7 +108,7 @@ def _same_root(left: str, right: str) -> bool: return bool(a and b and (a == b or a.endswith("." + b) or b.endswith("." + a))) -def _fetch(url: str) -> dict[str, Any]: +def _fetch(url: str, timeout_seconds: int = TIMEOUT_SECONDS) -> dict[str, Any]: current = url redirects = 0 started = time.monotonic() @@ -117,7 +120,7 @@ def _fetch(url: str) -> dict[str, Any]: return {"error": "unsafe_or_unresolvable_host", "url": current, "latency_ms": int((time.monotonic() - started) * 1000)} request = Request(current, headers={"User-Agent": "ProspectOS/1.0 (+public-site-check)", "Accept": "text/html,application/xhtml+xml"}) try: - with _opener.open(request, timeout=TIMEOUT_SECONDS) as response: + with _opener.open(request, timeout=timeout_seconds) as response: last_status = int(getattr(response, "status", 200) or 200) last_headers = response.headers body = response.read(MAX_BODY_BYTES) @@ -147,13 +150,13 @@ def _fetch(url: str) -> dict[str, Any]: return {"url": current, "initial_url": url, "status": last_status, "headers": last_headers, "body": body, "latency_ms": int((time.monotonic() - started) * 1000), "redirects": redirects, "error": "redirect_limit"} -def _tls_expiry(url: str) -> str: +def _tls_expiry(url: str, timeout_seconds: int = TIMEOUT_SECONDS) -> str: parsed = urlparse(url) if parsed.scheme != "https" or not parsed.hostname: return "" try: context = ssl.create_default_context() - with socket.create_connection((parsed.hostname, parsed.port or 443), timeout=TIMEOUT_SECONDS) as raw: + with socket.create_connection((parsed.hostname, parsed.port or 443), timeout=timeout_seconds) as raw: with context.wrap_socket(raw, server_hostname=parsed.hostname) as secure: cert = secure.getpeercert() value = cert.get("notAfter") if cert else "" @@ -174,11 +177,15 @@ def _emails(text: str) -> list[str]: return found[:30] -def scan_website(url: str) -> dict[str, Any]: +def scan_website(url: str, timeout_seconds: int | None = None) -> dict[str, Any]: """Return bounded website health, metadata, and public email evidence.""" if not url: return {"website_status": "no_detected_website", "website_checked_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), "website_emails": [], "email_evidence": []} - first = _fetch(url) + try: + timeout = max(2, min(60, int(timeout_seconds if timeout_seconds is not None else TIMEOUT_SECONDS))) + except (TypeError, ValueError): + timeout = TIMEOUT_SECONDS + first = _fetch(url, timeout) status = int(first.get("status") or 0) final_url = str(first.get("url") or url) body = bytes(first.get("body") or b"") @@ -196,7 +203,7 @@ def scan_website(url: str) -> dict[str, Any]: candidate = urljoin(final_url, href) label = (href or "").lower() if any(token in label for token in ("contact", "about", "get-in-touch")) and _same_root(final_url, candidate) and _safe_url(candidate): - page = _fetch(candidate) + page = _fetch(candidate, timeout) pages.append(page) emails.extend(_emails(bytes(page.get("body") or b"").decode("utf-8", "replace"))) break @@ -230,7 +237,7 @@ def scan_website(url: str) -> dict[str, Any]: "website_title": title, "website_technology": technologies, "website_has_viewport": 1 if "viewport" in parser.meta else 0, - "website_tls_expires_at": _tls_expiry(final_url) if urlparse(final_url).scheme == "https" else "", + "website_tls_expires_at": _tls_expiry(final_url, timeout) if urlparse(final_url).scheme == "https" else "", "website_checked_at": checked, "website_error": str(first.get("error") or "")[:300], "website_emails": emails, diff --git a/api/server.py b/api/server.py index 7829665..84249bf 100644 --- a/api/server.py +++ b/api/server.py @@ -73,17 +73,71 @@ _schema_lock = threading.Lock() _schema_path: str | None = None +def _saved_settings() -> dict[str, str]: + """Read persisted runtime settings, falling back cleanly during startup.""" + try: + conn = db() + try: + return {str(row["key"]): str(row["value"]) for row in conn.execute("SELECT key,value FROM settings")} + finally: + conn.close() + except Exception: + return {} + + +def _setting_int(saved: dict[str, str], key: str, env_key: str, default: int, minimum: int, maximum: int) -> int: + try: + value = int(saved.get(key, os.environ.get(env_key, str(default)))) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def _setting_float(saved: dict[str, str], key: str, env_key: str, default: float, minimum: float, maximum: float) -> float: + try: + value = float(saved.get(key, os.environ.get(env_key, str(default)))) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def _setting_bool(saved: dict[str, str], key: str, env_key: str, default: bool) -> bool: + raw = str(saved.get(key, os.environ.get(env_key, "true" if default else "false"))).strip().lower() + return raw not in {"0", "false", "no", "off", "disabled"} + + +def browser_use_settings() -> dict[str, Any]: + """Return the live Browser Use controls from Settings or .env defaults.""" + saved = _saved_settings() + return { + "enabled": _setting_bool(saved, "BROWSER_USE_ENABLED", "BROWSER_USE_ENABLED", BROWSER_USE_ENABLED), + "max_steps": _setting_int(saved, "BROWSER_USE_MAX_STEPS", "BROWSER_USE_MAX_STEPS", 10, 3, 50), + "timeout_seconds": _setting_int(saved, "BROWSER_USE_TIMEOUT_SECONDS", "BROWSER_USE_TIMEOUT_SECONDS", 600, 30, 1800), + "llm_timeout_seconds": _setting_int(saved, "BROWSER_USE_LLM_TIMEOUT_SECONDS", "BROWSER_USE_LLM_TIMEOUT_SECONDS", 90, 10, 300), + "headless": True, + "public_only": True, + } + + def browser_use_limits() -> tuple[int, int]: - """Return bounded Browser Use steps/timeout even if .env is malformed.""" - try: - steps = int(os.environ.get("BROWSER_USE_MAX_STEPS", "10")) - except (TypeError, ValueError): - steps = 10 - try: - timeout = int(os.environ.get("BROWSER_USE_TIMEOUT_SECONDS", "120")) - except (TypeError, ValueError): - timeout = 120 - return max(3, min(15, steps)), max(30, min(300, timeout)) + """Return bounded Browser Use steps/timeout for compatibility with callers.""" + runtime = browser_use_settings() + return int(runtime["max_steps"]), int(runtime["timeout_seconds"]) + + +def gmaps_runtime_settings() -> dict[str, Any]: + """Return live Maps worker timing controls from Settings or .env defaults.""" + saved = _saved_settings() + return { + "request_timeout_seconds": _setting_int(saved, "GMAPS_REQUEST_TIMEOUT", "GMAPS_REQUEST_TIMEOUT", GMAPS_REQUEST_TIMEOUT, 60, 7200), + "poll_seconds": _setting_float(saved, "GMAPS_POLL_SECONDS", "GMAPS_POLL_SECONDS", GMAPS_POLL_SECONDS, 0.5, 60.0), + "max_depth": _setting_int(saved, "GMAPS_MAX_DEPTH", "GMAPS_MAX_DEPTH", 1, 1, 10), + } + + +def website_scan_timeout_seconds() -> int: + saved = _saved_settings() + return _setting_int(saved, "WEBSITE_SCAN_TIMEOUT", "WEBSITE_SCAN_TIMEOUT", 8, 2, 60) def now() -> str: @@ -730,7 +784,9 @@ def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: boo raise RuntimeError("gmaps_not_configured") include_email = True lang = os.environ.get("GMAPS_LANG", "en") - depth = max(1, min(10, int(os.environ.get("GMAPS_MAX_DEPTH", "1")))) + maps_runtime = gmaps_runtime_settings() + depth = int(maps_runtime["max_depth"]) + request_timeout = int(maps_runtime["request_timeout_seconds"]) if GMAPS_API_MODE == "saas": endpoint = "/api/v1/scrape" payload = { @@ -739,7 +795,7 @@ def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: boo "max_depth": depth, "email": bool(include_email), "extra_reviews": bool(extra_reviews), - "timeout": max(1, min(300, GMAPS_REQUEST_TIMEOUT)), + "timeout": max(1, min(7200, request_timeout)), } else: # Standard gosom web mode uses the local job API. It has no API-key @@ -753,7 +809,7 @@ def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: boo "depth": depth, "email": bool(include_email), "extra_reviews": bool(extra_reviews), - "max_time": max(60, min(3600, GMAPS_REQUEST_TIMEOUT)), + "max_time": max(60, min(7200, request_timeout)), } response = http_json("POST", GMAPS_URL + endpoint, payload, gmaps_headers(), timeout=30, error_prefix="gmaps") job_id = field_value(response, "job_id", "id", "JobID", "ID") @@ -765,7 +821,9 @@ def submit_gmaps_job(query: str, include_email: bool = False, extra_reviews: boo def poll_gmaps_job(job_id: str, progress: Callable[[str, int, int], None] | None = None) -> list[dict[str, Any]]: if not GMAPS_URL: raise RuntimeError("gmaps_not_configured") - deadline = time.monotonic() + max(60, GMAPS_REQUEST_TIMEOUT + 60) + maps_runtime = gmaps_runtime_settings() + deadline = time.monotonic() + max(60, int(maps_runtime["request_timeout_seconds"]) + 60) + poll_seconds = float(maps_runtime["poll_seconds"]) if GMAPS_API_MODE == "saas": terminal_ok = {"completed", "succeeded", "success"} else: @@ -786,7 +844,7 @@ def poll_gmaps_job(job_id: str, progress: Callable[[str, int, int], None] | None if status in terminal_bad: error = field_value(response, "error", "Error") raise RuntimeError("gmaps_job_" + status + (":" + as_text(error) if error else "")) - time.sleep(max(0.5, GMAPS_POLL_SECONDS)) + time.sleep(poll_seconds) # The worker can finish writing its CSV just after its status endpoint # exceeds our polling window. Recover a completed/partial result when it # is available rather than discarding the run as a false failure. @@ -995,9 +1053,9 @@ def enrich_place(item: dict[str, Any]) -> dict[str, Any]: if item.get("website"): scan_started = time.monotonic() record_log("search", "website_scan_started", prospect_id=as_int(item.get("id")) or None, - details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website")))}) + details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website"))), "timeout_seconds": website_scan_timeout_seconds()}) try: - item.update(scan_website(str(item["website"]))) + item.update(scan_website(str(item["website"]), timeout_seconds=website_scan_timeout_seconds())) record_log("search", "website_scan_completed", prospect_id=as_int(item.get("id")) or None, duration_ms=int((time.monotonic() - scan_started) * 1000), details={"business": item.get("name"), "url": _safe_log_url(str(item.get("website"))), "status": item.get("website_status"), "emails_found": len(item.get("website_emails") or [])}) @@ -1388,6 +1446,7 @@ class Handler(BaseHTTPRequestHandler): auth_required = GMAPS_API_MODE == "saas" ready = backend_mode() == "browser" or (bool(GMAPS_URL) and (not auth_required or bool(GMAPS_API_KEY))) ai_runtime = ai_runtime_settings() + browser_runtime = browser_use_settings() return self.send_json(200, { "backend": backend_mode(), "gmaps_url": bool(GMAPS_URL), @@ -1397,7 +1456,7 @@ class Handler(BaseHTTPRequestHandler): "ready": ready, "email_extraction_default": True, "email_extraction_always": True, - "browser_use_enabled": BROWSER_USE_ENABLED, + "browser_use_enabled": browser_runtime["enabled"], "ai_provider": ai_runtime["provider"], "ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), }) @@ -1442,8 +1501,20 @@ class Handler(BaseHTTPRequestHandler): if parsed.path == "/api/settings": ai_runtime = ai_runtime_settings() models = nous_models() if ai_runtime["provider"] == "nous" else [] - browser_steps, browser_timeout = browser_use_limits() - return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, "browser_use": {"enabled": BROWSER_USE_ENABLED, "headless": True, "public_only": True, "max_steps": browser_steps, "timeout_seconds": browser_timeout}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}}) + browser_runtime = browser_use_settings() + maps_runtime = gmaps_runtime_settings() + scan_timeout = website_scan_timeout_seconds() + return self.send_json(200, { + "maps": { + "backend": backend_mode(), "worker_url": GMAPS_URL, + "ready": backend_mode() == "browser" or bool(GMAPS_URL), + **maps_runtime, + }, + "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, + "browser_use": browser_runtime, + "email_collection": {"always_on": True, "public_only": True}, + "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout}, + }) if parsed.path == "/api/prospects": params = parse_qs(parsed.query) q = (params.get("q", [""])[0]).strip().lower() @@ -1546,15 +1617,58 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(400, {"error": str(exc)}) if self.path == "/api/settings": try: - payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {} + payload = self.read_json() + ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {} + browser = payload.get("browser_use") if isinstance(payload.get("browser_use"), dict) else {} + maps = payload.get("maps") if isinstance(payload.get("maps"), dict) else {} + limits = payload.get("limits") if isinstance(payload.get("limits"), dict) else {} conn = db() + saved_keys: list[str] = [] + + def save_setting(key: str, value: Any) -> None: + text_value = str(value).strip()[:500] + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, text_value, now())) + saved_keys.append(key) + for key in ("AI_PROVIDER", "AI_BASE_URL", "AI_API_KEY", "AI_MODEL"): if key in ai: - value = str(ai.get(key) or "").strip()[:500] - conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, value, now())) - conn.commit(); saved={str(r["key"]):str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}; conn.close() + # An empty password means “keep the existing credential” + # so a normal Settings save cannot erase a working key. + if key == "AI_API_KEY" and not str(ai.get(key) or "").strip(): + continue + save_setting(key, ai.get(key) or "") + + if "enabled" in browser: + enabled_value = str(browser.get("enabled") or "").strip().lower() not in {"0", "false", "no", "off", "disabled"} + save_setting("BROWSER_USE_ENABLED", "true" if enabled_value else "false") + if "max_steps" in browser: + save_setting("BROWSER_USE_MAX_STEPS", max(3, min(50, int(browser.get("max_steps"))))) + if "timeout_seconds" in browser: + save_setting("BROWSER_USE_TIMEOUT_SECONDS", max(30, min(1800, int(browser.get("timeout_seconds"))))) + if "llm_timeout_seconds" in browser: + save_setting("BROWSER_USE_LLM_TIMEOUT_SECONDS", max(10, min(300, int(browser.get("llm_timeout_seconds"))))) + + if "request_timeout_seconds" in maps: + save_setting("GMAPS_REQUEST_TIMEOUT", max(60, min(7200, int(maps.get("request_timeout_seconds"))))) + if "poll_seconds" in maps: + save_setting("GMAPS_POLL_SECONDS", max(0.5, min(60.0, float(maps.get("poll_seconds"))))) + if "max_depth" in maps: + save_setting("GMAPS_MAX_DEPTH", max(1, min(10, int(maps.get("max_depth"))))) + if "website_scan_timeout_seconds" in limits: + save_setting("WEBSITE_SCAN_TIMEOUT", max(2, min(60, int(limits.get("website_scan_timeout_seconds"))))) + + conn.commit() + conn.close() + record_log("action", "settings_updated", details={"keys": saved_keys}) ai_runtime = ai_runtime_settings() - return self.send_json(200, {"saved": True, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))}}) + return self.send_json(200, { + "saved": True, + "saved_keys": saved_keys, + "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))}, + "browser_use": browser_use_settings(), + "maps": gmaps_runtime_settings(), + "limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds()}, + }) except Exception as exc: return self.send_json(400, {"error": str(exc)}) if urlparse(self.path).path == "/api/ai/test": @@ -1584,12 +1698,15 @@ class Handler(BaseHTTPRequestHandler): conn.close() if not row: return self.send_json(404, {"error": "not_found"}) - if not BROWSER_USE_ENABLED: + browser_runtime = browser_use_settings() + if not browser_runtime["enabled"]: return self.send_json(503, {"error": "browser_use_disabled"}) try: runtime = ai_runtime_settings() record_log("browser", "website_investigation_started", prospect_id=prospect_id, - details={"business": row["name"], "model": runtime["model"], "public_only": True}) + details={"business": row["name"], "model": runtime["model"], "public_only": True, + "max_steps": browser_runtime["max_steps"], "timeout_seconds": browser_runtime["timeout_seconds"], + "llm_timeout_seconds": browser_runtime["llm_timeout_seconds"]}) def browser_log(kind: str, event: str, details: dict[str, Any]) -> None: record_log(kind, event, prospect_id=prospect_id, details=details) @@ -1597,6 +1714,8 @@ class Handler(BaseHTTPRequestHandler): candidates = investigate_websites( dict(row), base_url=runtime["base_url"], api_key=runtime["api_key"], model=runtime["model"], user_agent=OUTBOUND_USER_AGENT, log_callback=browser_log, + enabled=browser_runtime["enabled"], max_steps=browser_runtime["max_steps"], + timeout_seconds=browser_runtime["timeout_seconds"], llm_timeout_seconds=browser_runtime["llm_timeout_seconds"], ) record_log("browser", "website_investigation_completed", prospect_id=prospect_id, details={"candidate_count": len(candidates), "model": runtime["model"]}) diff --git a/docker-compose.yml b/docker-compose.yml index 7d70c33..ef0021d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,8 @@ services: OUTBOUND_USER_AGENT: ${OUTBOUND_USER_AGENT:-ProspectOS/2.0 (+personal-use)} BROWSER_USE_ENABLED: ${BROWSER_USE_ENABLED:-true} BROWSER_USE_MAX_STEPS: ${BROWSER_USE_MAX_STEPS:-10} - BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-120} + BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-600} + BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90} ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false} LOG_RETENTION: ${LOG_RETENTION:-20000} AI_PROVIDER: ${AI_PROVIDER:-custom} diff --git a/tests/test_app.py b/tests/test_app.py index 7aaf5ce..8f85c9c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -211,6 +211,25 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertEqual(status, 200) self.assertTrue(any(row["event"] == "ui_test" for row in body["items"])) + def test_runtime_settings_persist_and_change_live_limits(self): + status, body = self.request("POST", "/api/settings", { + "browser_use": {"enabled": True, "max_steps": 22, "timeout_seconds": 900, "llm_timeout_seconds": 120}, + "maps": {"request_timeout_seconds": 1800, "poll_seconds": 4, "max_depth": 3}, + "limits": {"website_scan_timeout_seconds": 15}, + }) + self.assertEqual(status, 200) + self.assertEqual(body["browser_use"]["timeout_seconds"], 900) + self.assertEqual(body["maps"]["request_timeout_seconds"], 1800) + self.assertEqual(body["limits"]["website_scan_timeout_seconds"], 15) + status, settings = self.request("GET", "/api/settings") + self.assertEqual(status, 200) + self.assertEqual(settings["browser_use"]["max_steps"], 22) + self.assertEqual(settings["browser_use"]["llm_timeout_seconds"], 120) + self.assertEqual(settings["maps"]["poll_seconds"], 4.0) + self.assertEqual(settings["maps"]["max_depth"], 3) + self.assertEqual(server.browser_use_limits(), (22, 900)) + self.assertEqual(server.website_scan_timeout_seconds(), 15) + if __name__ == "__main__": unittest.main() diff --git a/web/app.js b/web/app.js index f15eb9d..095bc5f 100644 --- a/web/app.js +++ b/web/app.js @@ -25,7 +25,33 @@ function logTabs(counts={}){return `
${LOG_TABS.map(([ function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`
${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return ``}).join('')}
TimeTypeEventStatusDurationDetails
${esc(date(row.created_at))}${esc(row.kind||'system')}${esc(row.event||'')}${row.path?`${esc(row.method||'')} ${esc(row.path)}`:''}${row.error?`${esc(row.error)}`:''}${row.status?esc(row.status):'—'}${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}
View
${esc(details)}
`:empty('No log entries for this tab')} async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}} function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`
OPERATIONS JOURNAL

Detailed activity

Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.

${logTabs()}
Loading logs…
`);refreshLogs()} -function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`
SOURCE

Google Maps worker

The bundled standard gosom worker runs locally and does not require an API key.

${settings.maps?.ready?'Ready':'Not connected'}
Connection${esc(settings.maps?.worker_url||'Not configured')}
Email extractionAlways on · public pages only
Website scan8 second bounded timeout
AI PROVIDER

Draft assistant

Used only when you click “Draft AI email”.

${ai.configured?'Configured':'Not configured'}

${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}

Nous model availability can change. Refresh Settings to update the free catalog.

SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
`)} +function renderSettings(){ + const ai=settings.ai||{}; + const maps=settings.maps||{}; + const browser=settings.browser_use||{}; + const limits=settings.limits||{}; + const sch=settings.schedules||[]; + const sup=settings.suppressions||[]; + const provider=ai.provider||'custom'; + const modelList=Array.isArray(ai.models)?ai.models:[]; + const selectedModel=ai.model||''; + const options=modelList.map(m=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:''); + $('view').innerHTML=layout('Settings','All provider, browser, run, and safety controls live here. Daily discovery only asks for the search criteria.',` +
+
+
RUNTIME CONTROLS

Browser and discovery timing

These values are saved in the local database and apply to new work immediately. Increase the Browser Use timeout when website investigations need more time.

${maps.ready?'Maps ready':'Maps not connected'}
+
+

Browser Use website search

Headless, public-only browsing for possible official domains. It never logs in or bypasses challenge pages.

+

Google Maps worker

The local gosom worker is asynchronous. A run is allowed to finish before results are downloaded.

Connection${esc(maps.worker_url||'Not configured')}
+

Website health and email scan

Every public website is checked for health signals and public email addresses. Email collection remains always on.

+
+
+
+
AI PROVIDER

Draft assistant

Used when you assess a prospect or draft an email.

${ai.configured?'Configured':'Not configured'}

${provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.'}

Nous model availability can change. Refresh Settings to update the free catalog.

+
SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
+
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
+
`) +} async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);} async function route(){const r=location.hash.slice(1)||'dashboard';installLogsNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}} function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} @@ -35,7 +61,25 @@ async function openMessage(id){selected=prospects.find(p=>String(p.id)===String( function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

    ${reasons.map(r=>`
  • ${esc(r)}
  • `).join('')||'
  • No score reasons recorded
  • '}
${p.website?`Open website ↗`:''}
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')} document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}}); document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}}); -document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}}); +document.addEventListener('submit',async e=>{ + if(e.target.id==='discoverForm'){ + e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…'; + try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false} + } + if(e.target.id==='settingsForm'){ + e.preventDefault();const m=$('settingsMessage'); + try{const ai={AI_PROVIDER:$('aiProvider').value,AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='AI settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message;m.className='notice error'} + } + if(e.target.id==='runtimeSettingsForm'){ + e.preventDefault();const m=$('runtimeSettingsMessage');m.textContent='Saving…';m.className='notice'; + try{ + const result=await api('/api/settings',{method:'POST',body:JSON.stringify({browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)},maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)},limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value)}})}); + settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})};m.textContent='Runtime settings saved.';m.className='notice ok'; + }catch(x){m.textContent=x.message;m.className='notice error'} + } + if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}} + if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}} +}); document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Generating…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Review, edit, and confirm the facts before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}}); function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'} function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`

AI assessment

Contact probability${Math.round(Number(p.ai_probability))}%Recommendation${esc(aiRecommendationLabel(p.ai_recommendation))}

${esc(p.ai_reasoning||'No rationale returned.')}

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

Use the collected evidence to estimate whether this company is worth contacting.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)} diff --git a/web/index.html b/web/index.html index c813e15..5230fa0 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS +ProspectOS
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

Use the evidence and your offer to create an editable draft.

diff --git a/web/style.css b/web/style.css index db02027..ff9b15b 100644 --- a/web/style.css +++ b/web/style.css @@ -15,7 +15,7 @@ line-height: 1.45; } .message-dialog{width:min(680px,calc(100% - 32px));border:0;border-radius:18px;padding:0;box-shadow:0 24px 80px #11182733}.message-dialog::backdrop{background:#11182766}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px}.message-dialog textarea,.message-dialog input{width:100%;box-sizing:border-box;border:1px solid #d7dbea;border-radius:10px;padding:11px;font:inherit}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px} - +.runtime-settings-form{display:grid;gap:22px;margin-top:22px}.setting-group{padding:17px;border:1px solid #e7eaf2;border-radius:12px;background:#fbfcff}.setting-group h3{margin:0 0 5px;color:#334158;font-size:14px}.setting-group .field-help{margin:0 0 15px}.setting-group .setting-line{margin-top:0}.settings-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.settings-fields label{display:grid;gap:6px}.settings-fields small,.setting-group label>small{color:#8a96a8;font-size:10px;font-weight:500;line-height:1.4}.checkline input[type=checkbox]{width:auto;height:auto;margin:0}.runtime-settings-form .form-row{margin-top:-5px}@media(max-width:680px){.settings-fields{grid-template-columns:1fr}.runtime-settings-form .form-row{display:block}.runtime-settings-form .form-row .notice{display:block;margin-top:10px}} /* v7 workspace redesign */ *{box-sizing:border-box}body{background:#f4f6fa;color:#182338;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#0d1726;color:#cbd5e1;display:flex;flex-direction:column;padding:25px 16px;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 32px}.brand-mark{width:34px;height:34px;border-radius:10px;background:#635bff;color:white;display:grid;place-items:center;font-weight:900}.brand b{display:block;color:#fff;font-size:15px}.brand small{color:#8290a6;font-size:11px}.sidebar nav{display:grid;gap:5px}.sidebar nav a{color:#9eabc0;text-decoration:none;border-radius:9px;padding:12px 13px;display:flex;gap:12px;align-items:center;font-size:13px}.sidebar nav a span{width:18px;text-align:center;font-size:16px}.sidebar nav a:hover,.sidebar nav a.active{background:#1b2940;color:#fff}.sidebar nav a.active{box-shadow:inset 3px 0 #8179ff}.sidebar-foot{margin-top:auto;border-top:1px solid #233149;padding:17px 8px;display:flex;gap:9px;align-items:flex-start;font-size:11px}.sidebar-foot b{display:block;color:#dfe7f3;font-size:11px}.sidebar-foot small{color:#8190a7}.status-dot{width:8px;height:8px;border-radius:50%;background:#35c98b;margin-top:4px}.main{margin-left:238px;width:calc(100% - 238px);max-width:1500px;padding:0 42px 35px}.topbar{height:70px;border-bottom:1px solid #e5e9f1;display:flex;align-items:center;justify-content:space-between}.crumb{color:#6d7b90}.slash{padding:0 10px;color:#bac3d0}.top-actions{display:flex;gap:14px;align-items:center;color:#8a96a8;font-size:12px}.icon-btn{border:1px solid #dfe4ed;background:#fff;border-radius:9px;width:34px;height:34px;cursor:pointer;font-size:18px;color:#516079}.page-head{display:flex;justify-content:space-between;gap:24px;padding:38px 0 28px;align-items:end}.eyebrow{font-size:10px;letter-spacing:1.8px;color:#655bff;font-weight:800}.page-head h1{font-size:30px;letter-spacing:-.8px;margin:8px 0}.page-head p{margin:0;color:#708099;max-width:650px}.head-actions{display:flex;gap:8px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metric,.panel{background:#fff;border:1px solid #e3e8f0;border-radius:14px;box-shadow:0 3px 12px #1f2a3a08}.metric{padding:20px 21px}.metric small{display:block;color:#7b879c;letter-spacing:1.2px;font-size:10px;font-weight:800}.metric b{display:block;font-size:31px;margin:8px 0 2px;letter-spacing:-1px}.metric span{font-size:12px;color:#8894a6}.dashboard-grid,.two-col{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.panel{padding:23px}.panel-title{display:flex;justify-content:space-between;gap:15px;align-items:flex-start}.panel-title h2{font-size:18px;margin:6px 0}.panel-title p{color:#8491a5;font-size:12px;margin:0;line-height:1.5}.panel-title a{color:#635bff;text-decoration:none;font-size:12px;font-weight:700}.primary,.secondary{border-radius:9px;padding:10px 15px;border:0;font:inherit;font-size:12px;font-weight:800;cursor:pointer;text-decoration:none}.primary{background:#635bff;color:#fff}.secondary{background:#fff;border:1px solid #d9e0eb;color:#344258}.spotlight{margin-top:18px}.spot{display:flex;width:100%;align-items:center;text-align:left;border:0;border-top:1px solid #edf0f5;background:transparent;padding:13px 2px;gap:12px;cursor:pointer;color:inherit}.spot>span:nth-child(2){flex:1}.spot b,.spot small{display:block}.spot small{color:#8995a8;font-size:11px;margin-top:3px}.spot>span:last-child{color:#a3adbc;font-size:20px}.score{display:inline-grid;place-items:center;width:35px;height:28px;border-radius:8px;font-weight:900;font-size:12px}.score.hot{background:#ffe7e9;color:#d14457}.score.warm{background:#fff2d2;color:#aa7510}.score.cool{background:#e7f0ff;color:#3768ba}.activity{margin-top:12px}.activity>div{display:flex;align-items:center;gap:10px;border-top:1px solid #edf0f5;padding:12px 0}.activity>div>span:nth-child(2){flex:1}.activity b,.activity small{display:block}.activity b{font-size:12px}.activity small,.activity time{font-size:11px;color:#8b96a8}.run-dot{width:8px;height:8px;border-radius:50%;background:#aeb7c5}.run-dot.succeeded{background:#2dbd80}.run-dot.failed{background:#dc5663}.empty{padding:38px;text-align:center;color:#8995a8}.empty span{display:block;font-size:28px;margin-bottom:8px}.form-panel{min-height:430px}.form-panel form{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:25px}.form-panel label,.settings-grid label{display:grid;gap:7px;color:#4d5c73;font-size:12px;font-weight:700}.form-panel label:first-child{grid-column:1/-1}.form-panel input,.form-panel select,.settings-grid input,.inline-form input,.inline-form select,.toolbar input,.toolbar select,.message-dialog input,.message-dialog textarea{border:1px solid #dbe2ed;border-radius:8px;padding:11px;background:#fff;font:inherit;color:#21304a;font-size:13px}.settings-grid input[readonly]{background:#f5f6fa;color:#7c8798}.field-help{margin:-7px 0 0;color:#8995a8;font-size:11px;line-height:1.45}.checkline{display:flex!important;grid-column:1/-1;align-items:center;gap:8px}.checkline small{font-weight:400;color:#8995a8}.form-row{grid-column:1/-1;display:flex;align-items:center;gap:12px}.notice{font-size:12px;color:#7c899c}.notice.ok{color:#14845d}.notice.error{color:#c44959}.pill,.badge,.status{font-size:10px;font-weight:800;padding:6px 9px;border-radius:99px;background:#ecebff;color:#5b53d8;white-space:nowrap}.steps{list-style:none;padding:0;margin:20px 0}.steps li{border-top:1px solid #edf0f5;padding:15px 0;display:grid;gap:4px}.steps span{font-size:12px;color:#8793a5}.toolbar{display:flex;gap:10px;margin-bottom:18px}.search{display:flex;align-items:center;border:1px solid #dbe2ed;border-radius:8px;background:#fff;padding:0 10px;flex:1}.search input{border:0;outline:0;width:100%}.toolbar select{width:170px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:#7e8a9c;font-size:10px;letter-spacing:.8px;text-transform:uppercase;padding:11px 10px;border-bottom:1px solid #e6ebf2}td{padding:14px 10px;border-bottom:1px solid #edf0f5;vertical-align:middle}td b,td small{display:block}td small{color:#8a96a8;font-size:11px;margin-top:4px}td a{color:#635bff;text-decoration:none;font-size:11px;display:block;margin-top:5px}.badge.good{background:#e2f7ee;color:#19815e}.badge.warn{background:#fff0d3;color:#aa7510}.email{color:#45546d}.table-wrap select{border:1px solid #dbe2ed;background:#fff;border-radius:7px;padding:7px;font-size:11px}.icon-action{border:0;background:#f0efff;color:#635bff;border-radius:7px;padding:8px;cursor:pointer}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.wide-panel{grid-column:1/-1}.setting-line{display:flex;justify-content:space-between;border-top:1px solid #edf0f5;padding:13px 0;margin-top:13px;color:#7d899b;font-size:12px}.setting-line b{color:#334158;text-align:right;max-width:60%;overflow-wrap:anywhere}.good-text{color:#14845d!important}.settings-grid form{display:grid;gap:14px;margin-top:18px}.inline-form{display:flex;gap:8px;margin-top:18px}.inline-form>*{flex:1}.list{margin-top:13px}.list>div{border-top:1px solid #edf0f5;padding:12px 0;display:flex;justify-content:space-between;gap:15px;font-size:12px}.list span{color:#8a96a8}.status.succeeded{background:#e1f6ed;color:#16815b}.status.failed{background:#ffe7e9;color:#bc4052}.status.running{background:#e8efff;color:#3d67af}footer{color:#96a1b2;font-size:11px;padding:28px 0}.message-dialog{width:min(700px,calc(100% - 30px));border:0;border-radius:15px;padding:0;box-shadow:0 30px 100px #121b2d44}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{margin:7px 0}.dialog-head p{color:#8995a8;font-size:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px} @media(max-width:900px){.sidebar{width:70px;padding:18px 10px}.brand div,.sidebar nav a:not(.active)::after,.sidebar-foot div{display:none}.brand{padding:0 8px 25px}.sidebar nav a{justify-content:center;padding:12px}.main{margin-left:70px;width:calc(100% - 70px);padding:0 20px}.metric-grid{grid-template-columns:repeat(2,1fr)}.settings-grid,.dashboard-grid,.two-col{grid-template-columns:1fr}.wide-panel{grid-column:auto}.toolbar{flex-wrap:wrap}.toolbar .search{flex-basis:100%}}