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
|
_client: httpx.AsyncClient | None = None
|
||||||
_proxy_ip_logged = False
|
_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:
|
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:
|
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:
|
if _proxy_ip_logged:
|
||||||
return
|
return
|
||||||
@@ -69,6 +72,9 @@ async def _log_proxy_ip_comparison(proxy_url: str) -> None:
|
|||||||
try:
|
try:
|
||||||
direct_ip = await _fetch_public_ip(direct_client)
|
direct_ip = await _fetch_public_ip(direct_client)
|
||||||
proxy_ip = await _fetch_public_ip(proxy_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(
|
logger.info(
|
||||||
"HTTPS proxy enabled: %s | public_ip_without_proxy=%s | public_ip_with_proxy=%s",
|
"HTTPS proxy enabled: %s | public_ip_without_proxy=%s | public_ip_with_proxy=%s",
|
||||||
_redact_proxy_url(proxy_url),
|
_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:
|
async def get_client() -> httpx.AsyncClient:
|
||||||
"""Return a shared AsyncClient with keepalive connection pool."""
|
"""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:
|
if _client is None or _client.is_closed:
|
||||||
max_conns = int(os.getenv("HTTP_MAX_CONNECTIONS", "10"))
|
max_conns = int(os.getenv("HTTP_MAX_CONNECTIONS", "10"))
|
||||||
@@ -91,6 +97,13 @@ async def get_client() -> httpx.AsyncClient:
|
|||||||
|
|
||||||
if proxy_url:
|
if proxy_url:
|
||||||
await _log_proxy_ip_comparison(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(
|
_client = httpx.AsyncClient(
|
||||||
timeout=float(os.getenv("HTTP_TIMEOUT_S", "30.0")),
|
timeout=float(os.getenv("HTTP_TIMEOUT_S", "30.0")),
|
||||||
@@ -112,6 +125,49 @@ async def get_client() -> httpx.AsyncClient:
|
|||||||
return _client
|
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:
|
async def close_client() -> None:
|
||||||
"""Close the shared AsyncClient. Call during shutdown."""
|
"""Close the shared AsyncClient. Call during shutdown."""
|
||||||
global _client
|
global _client
|
||||||
|
|||||||
@@ -56,6 +56,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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-label">Delivery Queue</div>
|
||||||
<div class="stat-value" style="font-size: 24px;">
|
<div class="stat-value" style="font-size: 24px;">
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from db import get_pool
|
from db import get_pool
|
||||||
|
from scraper import get_network_status, refresh_network_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
_country_cache: dict[str, tuple[float, str]] = {}
|
||||||
|
_COUNTRY_CACHE_TTL_S = 3600
|
||||||
|
|
||||||
|
|
||||||
async def query(sql: str, *args) -> list:
|
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())
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Web UI starting up")
|
logger.info("Web UI starting up")
|
||||||
@@ -110,6 +159,16 @@ async def dashboard(request: Request):
|
|||||||
{"request": request, "error": "Database unavailable", "data": None},
|
{"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 = {
|
data = {
|
||||||
"total_keywords": total_keywords or 0,
|
"total_keywords": total_keywords or 0,
|
||||||
"active_keywords": active_keywords or 0,
|
"active_keywords": active_keywords or 0,
|
||||||
@@ -120,6 +179,12 @@ async def dashboard(request: Request):
|
|||||||
"queue_dead": queue_dead or 0,
|
"queue_dead": queue_dead or 0,
|
||||||
"last_scheduler": last_scheduler["scraped_at"] if last_scheduler else None,
|
"last_scheduler": last_scheduler["scraped_at"] if last_scheduler else None,
|
||||||
"proxy_enabled": _proxy_enabled(),
|
"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})
|
return templates.TemplateResponse("dashboard.html", {"request": request, "error": None, "data": data})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user