From 30aef1fee84f8d9f6fdf963247f26e4df86e5f5b Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Thu, 21 May 2026 22:26:14 +0200 Subject: [PATCH] Simplify admin operator UI --- karldns/routers/admin.py | 31 ++-- karldns/web/styles.py | 216 +++++++++++++++++++++++++- karldns/web/templates_admin.py | 159 +++++++++++-------- karldns/web/templates_admin_tables.py | 63 ++++++-- karldns/web/templates_management.py | 2 +- tests/test_admin.py | 25 ++- tests/test_ui.py | 2 +- 7 files changed, 393 insertions(+), 105 deletions(-) diff --git a/karldns/routers/admin.py b/karldns/routers/admin.py index 8be92f9..471b9e3 100644 --- a/karldns/routers/admin.py +++ b/karldns/routers/admin.py @@ -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 diff --git a/karldns/web/styles.py b/karldns/web/styles.py index c9b1260..bb063ac 100644 --- a/karldns/web/styles.py +++ b/karldns/web/styles.py @@ -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() diff --git a/karldns/web/templates_admin.py b/karldns/web/templates_admin.py index d585dcb..7d6bda4 100644 --- a/karldns/web/templates_admin.py +++ b/karldns/web/templates_admin.py @@ -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"""
{_top_nav("Operator")} -
+
-

KarlDNS

-

Operator console

-

Review hostnames, updates, cleanup, and secret changes.

+
+
+

KarlDNS

+

Operator console

+
+ {totals_html} +
{notice_html or ""} -
+
-
-
-

Service health

-

Service status

-
-
{cleanup_panel} -
- {metrics_html} -
+ {metrics_html}
-
+
-
+
-

Hostnames

Hostnames

-

Showing {escape(len(accounts))} of {accounts_count}.

+

{escape(len(accounts))} of {accounts_count} shown.

{_admin_table_toolbar("hostnames", "Search hostnames")} @@ -110,13 +107,12 @@ def _render_admin_page(
-
+
-
+
-

Updates

Update events

-

Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.

+

{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.

{_admin_table_toolbar("update-events", "Search update events")} @@ -132,11 +128,37 @@ def _render_admin_page(
"""), - extra_styles=trusted(''), + extra_styles=trusted(''), extra_scripts=trusted(''), ) +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( + '
' + + "".join( + f'{escape(value)}{escape(label)}' + for label, value in items + ) + + "
" + ) + + +def _admin_notice(kind: str, message: str) -> SafeHtml: + alert_class = "alert-success" if kind == "success" else "alert-info" + return trusted(f""" +
+
{escape(message)}
+
+ """) + + 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""" -