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(