feat: enhance network status tracking and display in dashboard
CI / lint-and-test (push) Has been cancelled
CI / lint-and-test (push) Has been cancelled
This commit is contained in:
+58
-2
@@ -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
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user