feat(admin): add connectivity diagnostics page for domains and endpoints

This commit is contained in:
Oliver 2026-02-21 13:58:40 +01:00
parent 35ccceb260
commit 50f737f434
No known key found for this signature in database
4 changed files with 270 additions and 3 deletions

View file

@ -3,6 +3,9 @@ from __future__ import annotations
import json
from pathlib import Path
import re
import socket
import ssl
import time
from urllib.parse import urlparse
from urllib.parse import urlencode
from urllib.request import Request as UrlRequest, urlopen
@ -254,6 +257,113 @@ def _legal_checklist(article: dict, feed: dict | None) -> list[dict[str, str]]:
return checks
def _build_connectivity_targets() -> list[dict[str, str]]:
targets: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
def add_target(label: str, kind: str, value: str) -> None:
normalized = (value or "").strip()
if not normalized:
return
key = (kind, normalized.lower())
if key in seen:
return
seen.add(key)
targets.append({"label": label, "kind": kind, "value": normalized})
add_target("OpenAI API", "host", "api.openai.com")
if settings.wordpress_base_url:
parsed = urlparse(settings.wordpress_base_url)
if parsed.hostname:
add_target("WordPress Host", "host", parsed.hostname)
wp_api_url = f"{settings.wordpress_base_url.rstrip('/')}/wp-json/wp/v2"
add_target("WordPress REST", "url", wp_api_url)
for feed in list_feeds():
name = (feed.get("name") or "").strip() or f"Feed #{feed.get('id')}"
feed_url = str(feed.get("url") or "").strip()
if not feed_url:
continue
parsed = urlparse(feed_url)
if parsed.hostname:
add_target(f"{name} (Feed)", "host", parsed.hostname)
add_target(f"{name} (Feed URL)", "url", feed_url)
return targets
def _run_connectivity_check(target: dict[str, str]) -> dict[str, object]:
kind = target.get("kind", "")
value = str(target.get("value") or "")
row: dict[str, object] = {
"label": target.get("label") or "-",
"kind": kind,
"target": value,
"dns_ok": False,
"dns_info": "-",
"tcp_ok": False,
"tcp_info": "-",
"http_ok": False,
"http_info": "-",
"duration_ms": 0,
"ok": False,
}
started = time.perf_counter()
try:
hostname = value if kind == "host" else (urlparse(value).hostname or "")
port = 443
if kind == "url":
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"}:
row["http_info"] = f"unsupported scheme: {parsed.scheme or '-'}"
return row
port = 443 if parsed.scheme == "https" else 80
if not hostname:
row["dns_info"] = "host fehlt"
return row
try:
addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
ips = sorted({entry[4][0] for entry in addr_info if entry and len(entry) > 4 and entry[4]})
row["dns_ok"] = True
row["dns_info"] = ", ".join(ips[:3]) if ips else "resolved"
except Exception as exc:
row["dns_info"] = str(exc)
return row
try:
socket.create_connection((hostname, port), timeout=4).close()
row["tcp_ok"] = True
row["tcp_info"] = f"port {port} erreichbar"
except Exception as exc:
row["tcp_info"] = str(exc)
return row
if kind == "host":
row["http_ok"] = True
row["http_info"] = "n/a (host-only)"
row["ok"] = True
return row
try:
req = UrlRequest(
url=value,
headers={"User-Agent": IMAGE_PROXY_USER_AGENT, "Accept": "*/*"},
)
with urlopen(req, timeout=6, context=ssl.create_default_context()) as resp:
code = getattr(resp, "status", None) or resp.getcode()
row["http_ok"] = True
row["http_info"] = f"HTTP {code}"
except Exception as exc:
row["http_info"] = str(exc)
return row
row["ok"] = bool(row["dns_ok"] and row["tcp_ok"] and row["http_ok"])
return row
finally:
row["duration_ms"] = int((time.perf_counter() - started) * 1000)
@router.get("/admin", response_class=HTMLResponse)
def admin_index(request: Request):
user = _admin_user(request)
@ -362,6 +472,29 @@ def admin_dashboard(request: Request):
)
@router.get("/admin/connectivity", response_class=HTMLResponse)
def admin_connectivity(request: Request):
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
checks = [_run_connectivity_check(target) for target in _build_connectivity_targets()]
ok_count = len([c for c in checks if c.get("ok")])
error_count = len(checks) - ok_count
return templates.TemplateResponse(
request,
"admin_connectivity.html",
{
"request": request,
"title": "Connectivity Check",
"user": user,
"checks": checks,
"ok_count": ok_count,
"error_count": error_count,
},
)
@router.get("/admin/articles/{article_id}", response_class=HTMLResponse)
def admin_article_detail(request: Request, article_id: int):
user = _admin_user(request)

View file

@ -0,0 +1,84 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }}</title>
<link rel="stylesheet" href="/admin/static/admin.css" />
</head>
<body>
<header class="topbar">
<div>
<h1>Connectivity Check</h1>
<p>Angemeldet als <strong>{{ user }}</strong></p>
</div>
<div class="row">
<a class="linkbtn" href="/admin/dashboard">Zurück</a>
<form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button>
</form>
</div>
</header>
<main class="container">
<section class="stats">
<article class="stat">
<div class="label">Checks</div>
<div class="value">{{ checks|length }}</div>
</article>
<article class="stat">
<div class="label">OK</div>
<div class="value">{{ ok_count }}</div>
</article>
<article class="stat">
<div class="label">Fehler</div>
<div class="value">{{ error_count }}</div>
</article>
<article class="stat">
<div class="label">Zeitpunkt</div>
<div class="value">Live</div>
</article>
</section>
<section class="card">
<h2>Ziele</h2>
<p class="subtle">Geprüft werden DNS-Auflösung, TCP-Erreichbarkeit und bei URLs ein HTTP-Request.</p>
<form method="get" action="/admin/connectivity" class="row">
<button type="submit">Checks neu ausführen</button>
</form>
</section>
<section class="card">
<h2>Ergebnis</h2>
<table>
<thead>
<tr><th>Status</th><th>Name</th><th>Typ</th><th>Ziel</th><th>DNS</th><th>TCP</th><th>HTTP</th><th>Dauer</th></tr>
</thead>
<tbody>
{% for c in checks %}
<tr>
<td>{% if c.ok %}<span class="badge ok">OK</span>{% else %}<span class="badge bad">Fehler</span>{% endif %}</td>
<td>{{ c.label }}</td>
<td>{{ c.kind }}</td>
<td><code>{{ c.target }}</code></td>
<td>
{% if c.dns_ok %}<span class="badge ok">OK</span>{% else %}<span class="badge bad">FAIL</span>{% endif %}
<div class="subtle">{{ c.dns_info }}</div>
</td>
<td>
{% if c.tcp_ok %}<span class="badge ok">OK</span>{% else %}<span class="badge bad">FAIL</span>{% endif %}
<div class="subtle">{{ c.tcp_info }}</div>
</td>
<td>
{% if c.http_ok %}<span class="badge ok">OK</span>{% else %}<span class="badge bad">FAIL</span>{% endif %}
<div class="subtle">{{ c.http_info }}</div>
</td>
<td>{{ c.duration_ms }} ms</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>

View file

@ -12,9 +12,12 @@
<h1>rss-news Admin Dashboard</h1>
<p>Angemeldet als <strong>{{ user }}</strong></p>
</div>
<form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button>
</form>
<div class="row">
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
<form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button>
</form>
</div>
</header>
<main class="container">

View file

@ -176,6 +176,53 @@ class TestAdminUi(unittest.TestCase):
self.assertEqual(res.status_code, 200)
self.assertIn("image/jpeg", res.headers.get("content-type", ""))
@patch("backend.app.admin_ui._run_connectivity_check")
@patch("backend.app.admin_ui._build_connectivity_targets")
def test_connectivity_page_renders(self, mock_targets, mock_check) -> None:
mock_targets.return_value = [
{"label": "OpenAI API", "kind": "host", "value": "api.openai.com"},
{"label": "WordPress REST", "kind": "url", "value": "https://example.org/wp-json/wp/v2"},
]
mock_check.side_effect = [
{
"label": "OpenAI API",
"kind": "host",
"target": "api.openai.com",
"dns_ok": True,
"dns_info": "1.2.3.4",
"tcp_ok": True,
"tcp_info": "port 443 erreichbar",
"http_ok": True,
"http_info": "n/a (host-only)",
"duration_ms": 12,
"ok": True,
},
{
"label": "WordPress REST",
"kind": "url",
"target": "https://example.org/wp-json/wp/v2",
"dns_ok": False,
"dns_info": "Name or service not known",
"tcp_ok": False,
"tcp_info": "-",
"http_ok": False,
"http_info": "-",
"duration_ms": 10,
"ok": False,
},
]
self.client.post(
"/admin/login",
data={"username": "admin", "password": "secret"},
follow_redirects=True,
)
res = self.client.get("/admin/connectivity", follow_redirects=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Connectivity Check", res.text)
self.assertIn("OpenAI API", res.text)
self.assertIn("WordPress REST", res.text)
if __name__ == "__main__":
unittest.main()