feat: enhance network status tracking and display in dashboard
CI / lint-and-test (push) Has been cancelled

This commit is contained in:
2026-07-12 21:52:43 +02:00
parent 05adf2035a
commit 27e6c29ee1
3 changed files with 149 additions and 2 deletions
+58 -2
View File
@@ -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
+26
View File
@@ -56,6 +56,32 @@
</div>
</div>
<div class="card stat-card" style="border-left: 4px solid var(--brand-primary);">
<div class="stat-label">Network Egress</div>
<div style="display: grid; gap: 10px; margin-top: 10px;">
<div>
<div class="stat-subtext muted">System IP</div>
<div style="font-size: 18px; font-weight: 600; line-height: 1.4;">
{{ data.system_country_flag }}
{{ data.system_public_ip or 'Unknown' }}
{% if data.system_country_code %}
<span style="font-size: 12px; color: var(--text-muted);">({{ data.system_country_code }})</span>
{% endif %}
</div>
</div>
<div>
<div class="stat-subtext muted">Last Used IP</div>
<div style="font-size: 18px; font-weight: 600; line-height: 1.4; color: var(--brand-primary);">
{{ data.last_used_country_flag }}
{{ data.last_used_public_ip or 'Unknown' }}
{% if data.last_used_country_code %}
<span style="font-size: 12px; color: var(--text-muted);">({{ data.last_used_country_code }})</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card stat-card" style="border-left: 4px solid {% if data.queue_dead > 0 %}var(--danger-text){% else %}var(--success-text){% endif %};">
<div class="stat-label">Delivery Queue</div>
<div class="stat-value" style="font-size: 24px;">
+65
View File
@@ -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})