Simplify admin operator UI

This commit is contained in:
StasonJatham committed 2026-05-21 22:26:14 +02:00
1 parent 0fc220b90b
commit 30aef1fee8
7 files changed
+393 -105

No files matched your search

+21 -10
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
@@ -15,7 +17,8 @@ from ..security import client_ip, require_admin_csrf
from ..services.ddns import normalize_hostname
from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
from ..web.html_utils import SafeHtml
from ..web.templates_admin import _admin_notice, _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000
@@ -29,7 +32,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: str | None = None,
notice: SafeHtml | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
@@ -59,7 +62,15 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
accounts_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
cleanup_done: bool = Query(False),
cleanup_unused: int = Query(0, ge=0),
cleanup_events: int = Query(0, ge=0),
) -> str:
notice = (
_admin_notice("success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.")
if cleanup_done
else None
)
return render_admin(
service,
settings,
@@ -69,6 +80,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
notice,
)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
@@ -160,15 +172,14 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
)
notice = _admin_secret_panel(
"Cleanup done",
[
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
_copy_row("Old update events removed", str(result["update_events"])),
],
redirect_query = urlencode(
{
"cleanup_done": "1",
"cleanup_unused": result["unused_accounts"],
"cleanup_events": result["update_events"],
}
)
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
return RedirectResponse(f"{admin_path}?{redirect_query}", status_code=303)
return router
+208 -8
View File
@@ -1091,6 +1091,131 @@ select:focus-visible {
ADMIN_CSS = """
.admin-console {
padding: 32px 0 18px;
background: var(--rp-canvas);
}
.admin-page-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.admin-page-title h1 {
font-size: 30px;
}
.admin-total-strip,
.admin-metric-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-total-strip span,
.admin-metric-strip span {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 6px 12px;
border: 1px solid var(--rp-line);
border-radius: 999px;
background: var(--rp-surface);
color: var(--rp-muted);
font-size: 13px;
white-space: nowrap;
}
.admin-total-strip strong,
.admin-metric-strip strong {
color: var(--rp-ink);
font-size: 14px;
font-weight: 600;
}
.admin-block {
padding: 18px 0;
}
.admin-block + .admin-block {
border-top: 1px solid var(--rp-soft-line);
}
.admin-section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
margin-bottom: 10px;
}
.admin-notice {
margin: 10px 0 0;
border-radius: 14px;
}
.admin-cleanup-bar {
display: grid;
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 2.3fr) auto;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--rp-soft-line);
}
.admin-cleanup-title {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-cleanup-title h2 {
font-size: 22px;
}
.admin-cleanup-title span {
color: var(--rp-muted);
font-size: 13px;
}
.admin-cleanup-times {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.admin-cleanup-times div {
min-width: 0;
}
.admin-cleanup-times dt {
margin: 0;
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
}
.admin-cleanup-times dd {
margin: 0;
overflow: hidden;
color: var(--rp-ink);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-cleanup-bar form {
justify-self: end;
}
.admin-metric-strip {
margin-top: 12px;
}
.metric-card {
display: grid;
gap: 6px;
@@ -1113,7 +1238,7 @@ ADMIN_CSS = """
}
.admin-table-scroll {
max-height: 560px;
max-height: 480px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
@@ -1161,8 +1286,9 @@ ADMIN_CSS = """
table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
font-size: 14px;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
@@ -1178,15 +1304,13 @@ table {
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
padding: 10px 9px;
text-align: left;
white-space: nowrap;
vertical-align: middle;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.status-table {
@@ -1209,6 +1333,51 @@ th {
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
white-space: nowrap;
}
.cell-hostname {
width: 16rem;
max-width: 16rem;
}
.cell-ip {
width: 10rem;
max-width: 12rem;
}
.cell-time {
width: 13rem;
max-width: 13rem;
}
.cell-status,
.cell-count,
.cell-delete {
width: 1%;
white-space: nowrap;
}
.cell-detail {
min-width: 18rem;
max-width: 28rem;
}
.cell-actions {
width: 13rem;
}
.admin-action-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-action-group .btn,
.cell-delete .btn {
min-height: 34px;
padding-inline: 12px;
white-space: nowrap;
}
.empty {
@@ -1218,6 +1387,37 @@ th {
}
@media (max-width: 860px) {
.admin-console {
padding-top: 24px;
}
.admin-page-title,
.admin-section-heading {
align-items: start;
flex-direction: column;
gap: 12px;
}
.admin-cleanup-bar {
grid-template-columns: 1fr;
align-items: start;
gap: 12px;
}
.admin-cleanup-times {
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.admin-cleanup-bar form {
justify-self: stretch;
width: 100%;
}
.admin-cleanup-bar button {
width: 100%;
}
.admin-table-shell .form-control {
max-width: none;
}
@@ -1233,7 +1433,7 @@ th {
th,
td {
padding: 12px 9px;
padding: 10px 8px;
}
}
""".strip()
+93 -66
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from urllib.parse import urlencode
from ..security import admin_csrf_token
@@ -57,43 +58,39 @@ def _render_admin_page(
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator")}
<section class="section admin-heading">
<section class="admin-console">
<div class="container">
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
<div class="admin-page-title">
<div>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div>
</section>
{notice_html or ""}
<section class="section">
<section class="admin-block">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
<div>
<p class="eyebrow">Service health</p>
<h2>Service status</h2>
</div>
</div>
{cleanup_panel}
<div class="mt-4">
{metrics_html}
</div>
{metrics_html}
</div>
</section>
<section class="section">
<section class="admin-block">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div class="admin-section-heading">
<div>
<p class="eyebrow">Hostnames</p>
<h2>Hostnames</h2>
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
@@ -110,13 +107,12 @@ def _render_admin_page(
</div>
</section>
<section class="section">
<section class="admin-block">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div class="admin-section-heading">
<div>
<p class="eyebrow">Updates</p>
<h2>Update events</h2>
<p class="section-copy mb-0">Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.</p>
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
@@ -132,11 +128,37 @@ def _render_admin_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=3" rel="stylesheet">'),
extra_styles=trusted('<link href="/admin.css?v=4" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
)
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
items = [
("Hostnames", table_counts.get("accounts", 0)),
("Events", table_counts.get("update_events", 0)),
("DNS failures", int(metrics.get("dns_failures_total", 0))),
("Blocked", int(metrics.get("rate_limit_hits_total", 0))),
]
return trusted(
'<div class="admin-total-strip" aria-label="Service totals">'
+ "".join(
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
for label, value in items
)
+ "</div>"
)
def _admin_notice(kind: str, message: str) -> SafeHtml:
alert_class = "alert-success" if kind == "success" else "alert-info"
return trusted(f"""
<div class="container">
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
</div>
""")
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit:
return trusted("")
@@ -149,7 +171,7 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
start = min(offset + 1, total)
end = min(offset + limit, total)
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
@@ -177,41 +199,27 @@ def _cleanup_panel(
if last_error
else trusted("")
)
cards = [
("Interval", f"*/{interval}s"),
("Last run", str(last_run or "Never")),
("Next run", str(next_run or "Pending")),
("Status", str(status)),
]
card_html = "\n".join(
f"""
<div class="col-12 col-md-6 col-xl-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
"""
for label, value in cards
)
last_run_title = attr(str(last_run or "Never"))
next_run_title = attr(str(next_run or "Pending"))
last_run_display = escape(_format_timestamp(last_run, "Never"))
next_run_display = escape(_format_timestamp(next_run, "Pending"))
return trusted(f"""
<div class="card flat-card">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
<div>
<h3 class="h5 mb-2">Cleanup</h3>
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
{error_html}
</div>
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
</form>
</div>
<div class="row g-3 mt-3">
{card_html}
</div>
<div class="admin-cleanup-bar">
<div class="admin-cleanup-title">
<h2>Cleanup</h2>
<span>Every {escape(_format_interval(interval))}</span>
</div>
<dl class="admin-cleanup-times">
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
</dl>
{error_html}
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
</form>
</div>
""")
@@ -231,22 +239,41 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
]
cards = "\n".join(
f"""
<div class="col-6 col-lg-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
<span><strong>{escape(value)}</strong>{escape(label)}</span>
"""
for label, value in items
)
return trusted(f"""
<div class="mt-4">
<p class="eyebrow">Metrics</p>
<div class="row g-3 mt-1">{cards}</div>
<div class="admin-metric-strip" aria-label="Service metrics">
{cards}
</div>
""")
def _metric_int(value: float) -> str:
return str(int(value))
def _format_interval(seconds: object) -> str:
try:
value = int(seconds)
except (TypeError, ValueError):
return str(seconds)
if value % 3600 == 0:
hours = value // 3600
return f"{hours}h"
if value % 60 == 0:
minutes = value // 60
return f"{minutes}m"
return f"{value}s"
def _format_timestamp(value: object, fallback: str = "-") -> str:
if not value:
return fallback
raw = str(value)
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return raw
return parsed.strftime("%Y-%m-%d %H:%M")
+46 -17
View File
@@ -1,32 +1,43 @@
from __future__ import annotations
from datetime import datetime
from .components import _event_label
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"])
raw_hostname = str(account["hostname"])
hostname = escape(raw_hostname)
hostname_attr = attr(raw_hostname)
status = "Active" if account.get("accepted_at") else "Pending"
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
ipv4_raw = str(account.get("ipv4") or "-")
ipv6_raw = str(account.get("ipv6") or "-")
updated_raw = str(account.get("updated_at") or "Never")
ipv4 = escape(ipv4_raw)
ipv6 = escape(ipv6_raw)
updated = escape(_short_time(updated_raw, "Never"))
safe_admin_path = attr(admin_path)
return f"""
<tr>
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="d-flex gap-2 flex-wrap">
<td class="cell-hostname" title="{hostname_attr}">{hostname}</td>
<td class="cell-status">{escape(status)}</td>
<td class="cell-ip" title="{attr(ipv4_raw)}">{ipv4}</td>
<td class="cell-ip" title="{attr(ipv6_raw)}">{ipv6}</td>
<td class="cell-time" title="{attr(updated_raw)}">{updated}</td>
<td class="cell-actions">
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
<input type="hidden" name="hostname" value="{hostname_attr}">
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
</form>
</td>
<td>
<td class="cell-delete">
<form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
<input type="hidden" name="hostname" value="{hostname_attr}">
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
</form>
</td>
</tr>
@@ -67,11 +78,29 @@ def _admin_table_pagination() -> str:
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
created_raw = str(event.get("created_at") or "-")
hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-")
created = escape(_short_time(created_raw))
hostname = escape(hostname_raw)
status = escape(_event_label(event.get("status")))
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
detail = escape(detail_raw)
return (
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
)
def _short_time(value: str, fallback: str = "-") -> str:
if not value or value == fallback:
return fallback
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
return parsed.strftime("%Y-%m-%d %H:%M")
+1 -1
View File
@@ -142,7 +142,7 @@ def _render_management_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=3" rel="stylesheet">'),
extra_styles=trusted('<link href="/admin.css?v=4" rel="stylesheet">'),
)
def _render_pending_management_page(
+23 -2
View File
@@ -15,7 +15,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert '<link href="/admin.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/admin.css?v=4" rel="stylesheet">' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
@@ -28,7 +28,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert "Cleanup" in response.text
assert "Run now" in response.text
assert "Next run" in response.text
assert "Metrics" in response.text
assert "Service metrics" in response.text
assert "Blocked requests" in response.text
assert "Hostnames" in response.text
assert "Update events" in response.text
@@ -181,6 +181,27 @@ def test_admin_posts_require_csrf(tmp_path: Path) -> None:
assert response.status_code == 403
def test_admin_cleanup_uses_post_redirect_get(tmp_path: Path) -> None:
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")
app = make_app(settings)
client = TestClient(app)
response = client.post(
"/admin/cleanup",
auth=("admin", "admin"),
data={"csrf": admin_csrf_token(settings)},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"].startswith("/admin?cleanup_done=1")
redirected = client.get(response.headers["location"], auth=("admin", "admin"))
assert redirected.status_code == 200
assert "Cleanup finished." in redirected.text
assert "Copy these values now" not in redirected.text
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
+1 -1
View File
@@ -202,7 +202,7 @@ def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert '<link href="/admin.css?v=3" rel="stylesheet">' in management.text
assert '<link href="/admin.css?v=4" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Router fields" in management.text