From 27e6c29ee1b5119f9729e6316a5e28f9b1a61cb1 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 12 Jul 2026 21:52:43 +0200 Subject: [PATCH] feat: enhance network status tracking and display in dashboard --- worker/src/scraper.py | 60 +++++++++++++++++++++++++- worker/src/templates/dashboard.html | 26 ++++++++++++ worker/src/web.py | 65 +++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/worker/src/scraper.py b/worker/src/scraper.py index b1095aa..6a289e3 100644 --- a/worker/src/scraper.py +++ b/worker/src/scraper.py @@ -11,6 +11,9 @@ logger = logging.getLogger(__name__) _client: httpx.AsyncClient | None = None _proxy_ip_logged = False +_system_public_ip: str | None = None +_proxy_public_ip: str | None = None +_last_used_public_ip: str | None = None def _get_proxy_url() -> str | None: @@ -56,7 +59,7 @@ async def _fetch_public_ip(client: httpx.AsyncClient) -> str | None: async def _log_proxy_ip_comparison(proxy_url: str) -> None: - global _proxy_ip_logged + global _proxy_ip_logged, _system_public_ip, _proxy_public_ip, _last_used_public_ip if _proxy_ip_logged: return @@ -69,6 +72,9 @@ async def _log_proxy_ip_comparison(proxy_url: str) -> None: try: direct_ip = await _fetch_public_ip(direct_client) proxy_ip = await _fetch_public_ip(proxy_client) + _system_public_ip = direct_ip + _proxy_public_ip = proxy_ip + _last_used_public_ip = proxy_ip or direct_ip logger.info( "HTTPS proxy enabled: %s | public_ip_without_proxy=%s | public_ip_with_proxy=%s", _redact_proxy_url(proxy_url), @@ -82,7 +88,7 @@ async def _log_proxy_ip_comparison(proxy_url: str) -> None: async def get_client() -> httpx.AsyncClient: """Return a shared AsyncClient with keepalive connection pool.""" - global _client + global _client, _system_public_ip, _last_used_public_ip if _client is None or _client.is_closed: max_conns = int(os.getenv("HTTP_MAX_CONNECTIONS", "10")) @@ -91,6 +97,13 @@ async def get_client() -> httpx.AsyncClient: if proxy_url: await _log_proxy_ip_comparison(proxy_url) + elif _system_public_ip is None: + direct_client = httpx.AsyncClient(timeout=10.0, trust_env=False) + try: + _system_public_ip = await _fetch_public_ip(direct_client) + _last_used_public_ip = _system_public_ip + finally: + await direct_client.aclose() _client = httpx.AsyncClient( timeout=float(os.getenv("HTTP_TIMEOUT_S", "30.0")), @@ -112,6 +125,49 @@ async def get_client() -> httpx.AsyncClient: return _client +async def refresh_network_status() -> dict[str, str | bool | None]: + """Ensure network status has best-effort values even before first scrape.""" + global _system_public_ip, _proxy_public_ip, _last_used_public_ip + + proxy_url = _get_proxy_url() + + if _system_public_ip is None: + direct_client = httpx.AsyncClient(timeout=10.0, trust_env=False) + try: + _system_public_ip = await _fetch_public_ip(direct_client) + finally: + await direct_client.aclose() + + if proxy_url and _proxy_public_ip is None: + proxy_client = httpx.AsyncClient(timeout=10.0, trust_env=False, proxy=proxy_url) + try: + _proxy_public_ip = await _fetch_public_ip(proxy_client) + finally: + await proxy_client.aclose() + + if proxy_url: + _last_used_public_ip = _proxy_public_ip or _system_public_ip + else: + _last_used_public_ip = _system_public_ip + + return get_network_status() + + +def get_network_status() -> dict[str, str | bool | None]: + """Return best-effort network status for UI display.""" + proxy_enabled = bool(_get_proxy_url()) + last_used = _last_used_public_ip + if last_used is None: + last_used = _proxy_public_ip if proxy_enabled else _system_public_ip + + return { + "proxy_enabled": proxy_enabled, + "system_public_ip": _system_public_ip, + "proxy_public_ip": _proxy_public_ip, + "last_used_public_ip": last_used, + } + + async def close_client() -> None: """Close the shared AsyncClient. Call during shutdown.""" global _client diff --git a/worker/src/templates/dashboard.html b/worker/src/templates/dashboard.html index 05fb9fb..23362bd 100644 --- a/worker/src/templates/dashboard.html +++ b/worker/src/templates/dashboard.html @@ -56,6 +56,32 @@ +
+
Network Egress
+
+
+
System IP
+
+ {{ data.system_country_flag }} + {{ data.system_public_ip or 'Unknown' }} + {% if data.system_country_code %} + ({{ data.system_country_code }}) + {% endif %} +
+
+
+
Last Used IP
+
+ {{ data.last_used_country_flag }} + {{ data.last_used_public_ip or 'Unknown' }} + {% if data.last_used_country_code %} + ({{ data.last_used_country_code }}) + {% endif %} +
+
+
+
+
Delivery Queue
diff --git a/worker/src/web.py b/worker/src/web.py index b454c03..947c16a 100644 --- a/worker/src/web.py +++ b/worker/src/web.py @@ -1,17 +1,22 @@ import os import logging +import time from contextlib import asynccontextmanager +import httpx from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates import asyncpg from db import get_pool +from scraper import get_network_status, refresh_network_status logger = logging.getLogger(__name__) templates = Jinja2Templates(directory="templates") +_country_cache: dict[str, tuple[float, str]] = {} +_COUNTRY_CACHE_TTL_S = 3600 async def query(sql: str, *args) -> list: @@ -78,6 +83,50 @@ def _proxy_enabled() -> bool: return bool((os.getenv("HTTPS_PROXY") or os.getenv("https_proxy") or "").strip()) +def _flag_from_country_code(country_code: str | None) -> str: + if not country_code or len(country_code) != 2: + return "" + code = country_code.upper() + if not code.isalpha(): + return "" + return chr(127397 + ord(code[0])) + chr(127397 + ord(code[1])) + + +async def _country_code_for_ip(ip_addr: str | None) -> str | None: + if not ip_addr: + return None + + now = time.time() + cached = _country_cache.get(ip_addr) + if cached and now - cached[0] < _COUNTRY_CACHE_TTL_S: + return cached[1] + + try: + async with httpx.AsyncClient(timeout=5.0, trust_env=False) as client: + resp = await client.get(f"https://ipapi.co/{ip_addr}/country/") + resp.raise_for_status() + code = resp.text.strip().upper() + if len(code) == 2 and code.isalpha(): + _country_cache[ip_addr] = (now, code) + return code + except Exception: + logger.debug("Could not resolve country for IP %s", ip_addr, exc_info=True) + + try: + async with httpx.AsyncClient(timeout=5.0, trust_env=False) as client: + resp = await client.get(f"https://ipwho.is/{ip_addr}") + resp.raise_for_status() + payload = resp.json() + code = str(payload.get("country_code", "")).upper() + if len(code) == 2 and code.isalpha(): + _country_cache[ip_addr] = (now, code) + return code + except Exception: + logger.debug("Fallback country lookup failed for IP %s", ip_addr, exc_info=True) + + return None + + @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Web UI starting up") @@ -110,6 +159,16 @@ async def dashboard(request: Request): {"request": request, "error": "Database unavailable", "data": None}, ) + try: + network = await refresh_network_status() + except Exception: + logger.debug("Could not refresh network status", exc_info=True) + network = get_network_status() + system_ip = network.get("system_public_ip") + last_used_ip = network.get("last_used_public_ip") + system_country = await _country_code_for_ip(system_ip if isinstance(system_ip, str) else None) + last_used_country = await _country_code_for_ip(last_used_ip if isinstance(last_used_ip, str) else None) + data = { "total_keywords": total_keywords or 0, "active_keywords": active_keywords or 0, @@ -120,6 +179,12 @@ async def dashboard(request: Request): "queue_dead": queue_dead or 0, "last_scheduler": last_scheduler["scraped_at"] if last_scheduler else None, "proxy_enabled": _proxy_enabled(), + "system_public_ip": system_ip, + "last_used_public_ip": last_used_ip, + "system_country_code": system_country, + "last_used_country_code": last_used_country, + "system_country_flag": _flag_from_country_code(system_country), + "last_used_country_flag": _flag_from_country_code(last_used_country), } return templates.TemplateResponse("dashboard.html", {"request": request, "error": None, "data": data})