From dfe500c17875e24bd38927ac6e62fbba811a087c Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Thu, 11 Jun 2026 21:00:00 +0200 Subject: [PATCH] =?UTF-8?q?portal:=20favoritter+visning,=20app-/cookie-/op?= =?UTF-8?q?s-admin,=20=E2=8C=98K-palette,=20ikoner,=20grupper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 62 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + templates/admin_ops.html | 65 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 templates/admin_ops.html diff --git a/main.py b/main.py index 6ecdc91..4103e2c 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,11 @@ MATOMO_SITE = os.environ.get("MATOMO_SITE", "2") IP_LABELS_LOCAL = DATA / "ip_labels.json" IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") # fx root@192.168.10.201:/opt/npmplus/custom_nginx/ip_labels.json SETTINGS_PATH = DATA / "settings.json" +RP_BASE_URL = os.environ.get("RP_BASE_URL", "https://rp.weircon.dk").rstrip("/") +RP_API_KEY = os.environ.get("RP_API_KEY", "") +ASSETS_VERSION_URL = os.environ.get("ASSETS_VERSION_URL", "https://assets.weircon.dk/VERSION") +GEOIP_DB = os.environ.get("GEOIP_DB", str(DATA / "dbip-country.mmdb")) +_geoip_reader = None app = FastAPI(title="Weircon Portal") app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12) @@ -60,6 +65,8 @@ ADMIN_MODULES = [ "desc": "Hurtigt overblik fra Matomo."}, {"key": "apps", "title": "App-beskrivelser & grupper", "icon": "🧩", "desc": "Rediger beskrivelse og gruppe på dine Authentik-apps."}, + {"key": "ops", "title": "Drift / egress", "icon": "🛰️", + "desc": "Static-assets-version + random-proxy egress, rollover & inflight."}, ] @@ -187,6 +194,49 @@ async def patch_apps(rows): return n +def geoip_country(ip): + global _geoip_reader + try: + if _geoip_reader is None: + import maxminddb + _geoip_reader = maxminddb.open_database(GEOIP_DB) + rec = _geoip_reader.get(ip) or {} + c = rec.get("country") or {} + iso = c.get("iso_code") or "" + name = (c.get("names") or {}).get("en") or "" + flag = "".join(chr(0x1F1E6 + ord(ch) - 65) for ch in iso) if len(iso) == 2 and iso.isalpha() else "" + return {"iso": iso, "name": name, "flag": flag} + except Exception: + return {"iso": "", "name": "", "flag": ""} + + +async def assets_version(): + try: + async with httpx.AsyncClient(timeout=6) as c: + r = await c.get(ASSETS_VERSION_URL) + return r.text.strip()[:40] + except Exception: + return None + + +async def rp_status(): + if not RP_API_KEY: + return {"error": "ingen RP_API_KEY konfigureret"} + try: + async with httpx.AsyncClient(timeout=6) as c: + r = await c.get(RP_BASE_URL + "/status", headers={"X-WEIRCON-RANDOM-IP": RP_API_KEY}) + d = r.json() + for p in d.get("proxies", []): + p["geo"] = geoip_country(p.get("egress_ip") or "") + rot = d.get("rotation") or {} + if rot.get("next_egress_ip"): + rot["geo"] = geoip_country(rot["next_egress_ip"]) + d["inflight_total"] = sum(int(p.get("inflight") or 0) for p in d.get("proxies", [])) + return d + except Exception as exc: + return {"error": str(exc)[:200]} + + async def matomo_summary(): if not MATOMO_TOKEN: return None @@ -332,6 +382,18 @@ async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), co return RedirectResponse("/admin/cookies?saved=Gemt", status_code=303) +@app.get("/admin/ops", response_class=HTMLResponse) +async def admin_ops(request: Request): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403, "Kun for administratorer") + version = await assets_version() + rp = await rp_status() + return templates.TemplateResponse(request, "admin_ops.html", { + "request": request, "user": user, "is_admin": True, "version": version, "rp": rp, + }) + + @app.get("/admin/stats", response_class=HTMLResponse) async def admin_stats(request: Request): user = current_user(request) diff --git a/requirements.txt b/requirements.txt index 0e6bcd7..565b2a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ authlib httpx itsdangerous python-multipart +maxminddb diff --git a/templates/admin_ops.html b/templates/admin_ops.html new file mode 100644 index 0000000..8e3e0a6 --- /dev/null +++ b/templates/admin_ops.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Admin — drift{% endblock %} +{% block body %} +

Drift / egress

+

Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.

+ +

Static-assets

+
+
{{ version if version else '—' }}
+
deployet version (assets.weircon.dk)
+
+ +

Random-proxy egress + {% if rp and not rp.error %} + {{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt{% endif %} +

+{% if rp.error %} +
Kunne ikke nĂĄ random-proxy: {{ rp.error }}
+{% else %} +{% set rot = rp.rotation %} +{% if rot %} +
+
Næste rollover
+
+ slot {{ rot.next_slot }} + — {{ rot.next_egress_ip }} + {% if rot.geo and rot.geo.flag %}{{ rot.geo.flag }} {{ rot.geo.iso }}{% endif %} + ruller af om {{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }} + (interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden) +
+
+{% endif %} +
+ {% for p in rp.proxies %} +
+
+ #{{ p.id }} + + {{ 'âš  down' if not p.reachable else (p.state or 'live') }} +
+
{{ p.egress_ip or '–' }}
+
+ {% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight + {% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %} +
+ {% if rot and p.id == rot.next_slot %}
↻ ruller af næst
{% endif %} +
+ {% endfor %} +
+{% endif %} + + + +{% endblock %}