portal: favoritter+visning, app-/cookie-/ops-admin, ⌘K-palette, ikoner, grupper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,11 @@ MATOMO_SITE = os.environ.get("MATOMO_SITE", "2")
|
|||||||
IP_LABELS_LOCAL = DATA / "ip_labels.json"
|
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
|
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"
|
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 = FastAPI(title="Weircon Portal")
|
||||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12)
|
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."},
|
"desc": "Hurtigt overblik fra Matomo."},
|
||||||
{"key": "apps", "title": "App-beskrivelser & grupper", "icon": "🧩",
|
{"key": "apps", "title": "App-beskrivelser & grupper", "icon": "🧩",
|
||||||
"desc": "Rediger beskrivelse og gruppe på dine Authentik-apps."},
|
"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
|
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():
|
async def matomo_summary():
|
||||||
if not MATOMO_TOKEN:
|
if not MATOMO_TOKEN:
|
||||||
return None
|
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)
|
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)
|
@app.get("/admin/stats", response_class=HTMLResponse)
|
||||||
async def admin_stats(request: Request):
|
async def admin_stats(request: Request):
|
||||||
user = current_user(request)
|
user = current_user(request)
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ authlib
|
|||||||
httpx
|
httpx
|
||||||
itsdangerous
|
itsdangerous
|
||||||
python-multipart
|
python-multipart
|
||||||
|
maxminddb
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — drift{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Drift / egress</h1>
|
||||||
|
<p class="lead">Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.</p>
|
||||||
|
|
||||||
|
<h2>Static-assets</h2>
|
||||||
|
<div class="stat" style="max-width:18rem">
|
||||||
|
<div class="n">{{ version if version else '—' }}</div>
|
||||||
|
<div class="muted">deployet version (assets.weircon.dk)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="display:flex;align-items:baseline;gap:.8rem">Random-proxy egress
|
||||||
|
{% if rp and not rp.error %}<span class="muted" style="font-size:.8rem;letter-spacing:0;text-transform:none">
|
||||||
|
{{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% if rp.error %}
|
||||||
|
<div class="err">Kunne ikke nå random-proxy: {{ rp.error }}</div>
|
||||||
|
{% else %}
|
||||||
|
{% set rot = rp.rotation %}
|
||||||
|
{% if rot %}
|
||||||
|
<div class="stat" style="margin-bottom:1rem">
|
||||||
|
<div class="muted">Næste rollover</div>
|
||||||
|
<div style="margin-top:.3rem">
|
||||||
|
<strong>slot {{ rot.next_slot }}</strong>
|
||||||
|
— <span class="font-mono">{{ rot.next_egress_ip }}</span>
|
||||||
|
{% if rot.geo and rot.geo.flag %}<span title="{{ rot.geo.name }}">{{ rot.geo.flag }} {{ rot.geo.iso }}</span>{% endif %}
|
||||||
|
ruller af om <strong>{{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }}</strong>
|
||||||
|
<span class="muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">
|
||||||
|
{% for p in rp.proxies %}
|
||||||
|
<div class="slot{% if rot and p.id == rot.next_slot %} next{% endif %}">
|
||||||
|
<div class="slot-top">
|
||||||
|
<span class="slot-id">#{{ p.id }}</span>
|
||||||
|
<span class="slot-state {{ 'down' if not p.reachable else ('busy' if p.inflight else 'live') }}">
|
||||||
|
{{ '⚠ down' if not p.reachable else (p.state or 'live') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono" style="font-size:.92rem;overflow:hidden;text-overflow:ellipsis">{{ p.egress_ip or '–' }}</div>
|
||||||
|
<div class="muted" style="font-size:.84rem;margin-top:.2rem">
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
{% if rot and p.id == rot.next_slot %}<div class="slot-next">↻ ruller af næst</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.font-mono { font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||||
|
.slot { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:.9rem; padding:.8rem .9rem; }
|
||||||
|
.slot.next { border-color:#f5a623; box-shadow:0 0 0 1px color-mix(in srgb,#f5a623 40%,transparent); }
|
||||||
|
.slot-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:.35rem; }
|
||||||
|
.slot-id { font-weight:700; color:var(--muted); font-size:.85rem; }
|
||||||
|
.slot-state { font-size:.72rem; border-radius:999px; padding:.05rem .5rem; border:1px solid var(--border); color:var(--muted); }
|
||||||
|
.slot-state.live { color:var(--ok, #3ecf8e); border-color:color-mix(in srgb,#3ecf8e 40%,transparent); }
|
||||||
|
.slot-state.busy { color:#f5a623; border-color:color-mix(in srgb,#f5a623 40%,transparent); }
|
||||||
|
.slot-state.down { color:#f87171; border-color:color-mix(in srgb,#f87171 40%,transparent); }
|
||||||
|
.slot-next { color:#f5a623; font-size:.74rem; margin-top:.35rem; }
|
||||||
|
</style>
|
||||||
|
<script>setTimeout(function(){ location.reload(); }, 20000);</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user