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 __future__ import annotations
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool 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 ..services.ddns import normalize_hostname
from ..web.components import _copy_row from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form 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_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000 ADMIN_JSON_OFFSET_CAP = 10_000
@@ -29,7 +32,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
table_offsets: dict[str, int] | None = None, table_offsets: dict[str, int] | None = None,
notice: str | None = None, notice: SafeHtml | None = None,
) -> str: ) -> str:
table_offsets = table_offsets or {} table_offsets = table_offsets or {}
return _render_admin_page( return _render_admin_page(
@@ -59,7 +62,15 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
accounts_offset: int = Query(0, ge=0), accounts_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0), cleanup_runs_offset: int = Query(0, ge=0),
update_events_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: ) -> 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( return render_admin(
service, service,
settings, settings,
@@ -69,6 +80,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"cleanup_runs": cleanup_runs_offset, "cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset, "update_events": update_events_offset,
}, },
notice,
) )
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False) @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_event_history_hours,
settings.cleanup_pending_account_hours, settings.cleanup_pending_account_hours,
) )
notice = _admin_secret_panel( redirect_query = urlencode(
"Cleanup done", {
[ "cleanup_done": "1",
_copy_row("Unused hostnames removed", str(result["unused_accounts"])), "cleanup_unused": result["unused_accounts"],
_copy_row("Old update events removed", str(result["update_events"])), "cleanup_events": result["update_events"],
], }
) )
page = await run_in_threadpool(render_admin, service, settings, store, None, notice) return RedirectResponse(f"{admin_path}?{redirect_query}", status_code=303)
return HTMLResponse(page)
return router return router
+208 -8
View File
@@ -1091,6 +1091,131 @@ select:focus-visible {
ADMIN_CSS = """ 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 { .metric-card {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -1113,7 +1238,7 @@ ADMIN_CSS = """
} }
.admin-table-scroll { .admin-table-scroll {
max-height: 560px; max-height: 480px;
overflow: auto; overflow: auto;
background: var(--rp-surface); background: var(--rp-surface);
border-color: var(--rp-line) !important; border-color: var(--rp-line) !important;
@@ -1161,8 +1286,9 @@ ADMIN_CSS = """
table { table {
width: 100%; width: 100%;
min-width: 900px;
border-collapse: collapse; border-collapse: collapse;
font-size: 14px; font-size: 13px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--rp-ink); color: var(--rp-ink);
} }
@@ -1178,15 +1304,13 @@ table {
th, th,
td { td {
border-bottom: 1px solid var(--rp-soft-line); border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px; padding: 10px 9px;
text-align: left; text-align: left;
white-space: nowrap; vertical-align: middle;
} }
td { td {
max-width: 38rem; overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
} }
.status-table { .status-table {
@@ -1209,6 +1333,51 @@ th {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
background: var(--rp-surface-alt); 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 { .empty {
@@ -1218,6 +1387,37 @@ th {
} }
@media (max-width: 860px) { @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 { .admin-table-shell .form-control {
max-width: none; max-width: none;
} }
@@ -1233,7 +1433,7 @@ th {
th, th,
td { td {
padding: 12px 9px; padding: 10px 8px;
} }
} }
""".strip() """.strip()
+93 -66
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from ..security import admin_csrf_token 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)) 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)) 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) cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
return _page( return _page(
"KarlDNS Admin", "KarlDNS Admin",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav("Operator")} {_top_nav("Operator")}
<section class="section admin-heading"> <section class="admin-console">
<div class="container"> <div class="container">
<p class="eyebrow">KarlDNS</p> <div class="admin-page-title">
<h1>Operator console</h1> <div>
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p> <p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div> </div>
</section> </section>
{notice_html or ""} {notice_html or ""}
<section class="section"> <section class="admin-block">
<div class="container"> <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} {cleanup_panel}
<div class="mt-4"> {metrics_html}
{metrics_html}
</div>
</div> </div>
</section> </section>
<section class="section"> <section class="admin-block">
<div class="container"> <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> <div>
<p class="eyebrow">Hostnames</p>
<h2>Hostnames</h2> <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>
</div> </div>
{_admin_table_toolbar("hostnames", "Search hostnames")} {_admin_table_toolbar("hostnames", "Search hostnames")}
@@ -110,13 +107,12 @@ def _render_admin_page(
</div> </div>
</section> </section>
<section class="section"> <section class="admin-block">
<div class="container"> <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> <div>
<p class="eyebrow">Updates</p>
<h2>Update events</h2> <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>
</div> </div>
{_admin_table_toolbar("update-events", "Search update events")} {_admin_table_toolbar("update-events", "Search update events")}
@@ -132,11 +128,37 @@ def _render_admin_page(
</section> </section>
</main> </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>'), 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: def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit: if total <= limit:
return trusted("") 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) start = min(offset + 1, total)
end = min(offset + limit, total) end = min(offset + limit, total)
return trusted(f""" 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> <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{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> <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 if last_error
else trusted("") else trusted("")
) )
cards = [ last_run_title = attr(str(last_run or "Never"))
("Interval", f"*/{interval}s"), next_run_title = attr(str(next_run or "Pending"))
("Last run", str(last_run or "Never")), last_run_display = escape(_format_timestamp(last_run, "Never"))
("Next run", str(next_run or "Pending")), next_run_display = escape(_format_timestamp(next_run, "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
)
return trusted(f""" return trusted(f"""
<div class="card flat-card"> <div class="admin-cleanup-bar">
<div class="card-body p-4"> <div class="admin-cleanup-title">
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row"> <h2>Cleanup</h2>
<div> <span>Every {escape(_format_interval(interval))}</span>
<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> </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> </div>
""") """)
@@ -231,22 +239,41 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
] ]
cards = "\n".join( cards = "\n".join(
f""" f"""
<div class="col-6 col-lg-3"> <span><strong>{escape(value)}</strong>{escape(label)}</span>
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
""" """
for label, value in items for label, value in items
) )
return trusted(f""" return trusted(f"""
<div class="mt-4"> <div class="admin-metric-strip" aria-label="Service metrics">
<p class="eyebrow">Metrics</p> {cards}
<div class="row g-3 mt-1">{cards}</div>
</div> </div>
""") """)
def _metric_int(value: float) -> str: def _metric_int(value: float) -> str:
return str(int(value)) 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 __future__ import annotations
from datetime import datetime
from .components import _event_label from .components import _event_label
from .html_utils import attr, escape from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str: 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" status = "Active" if account.get("accepted_at") else "Pending"
ipv4 = escape(account.get("ipv4") or "-") ipv4_raw = str(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-") ipv6_raw = str(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never") 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) safe_admin_path = attr(admin_path)
return f""" return f"""
<tr> <tr>
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td> <td class="cell-hostname" title="{hostname_attr}">{hostname}</td>
<td> <td class="cell-status">{escape(status)}</td>
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="d-flex gap-2 flex-wrap"> <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="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}"> <input type="hidden" name="hostname" value="{hostname_attr}">
<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="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 px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</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> </form>
</td> </td>
<td> <td class="cell-delete">
<form method="post" action="{safe_admin_path}/hostnames/delete"> <form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}"> <input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}"> <input type="hidden" name="hostname" value="{hostname_attr}">
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button> <button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -67,11 +78,29 @@ def _admin_table_pagination() -> str:
def _event_row(event: dict[str, str | int | None]) -> str: def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-") created_raw = str(event.get("created_at") or "-")
hostname = escape(event.get("hostname") 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"))) status = escape(_event_label(event.get("status")))
ipv4 = escape(event.get("ipv4") or "-") ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-") ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1) count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-") detail = escape(detail_raw)
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>" 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> </section>
</main> </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( 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.status_code == 200
assert response.headers["cache-control"] == "no-store" 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 '<script src="/admin-tables.js?v=2" defer></script>' in response.text
assert "Operator console" in response.text assert "Operator console" in response.text
assert "Generate credentials" not 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 "Cleanup" in response.text
assert "Run now" in response.text assert "Run now" in response.text
assert "Next run" 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 "Blocked requests" in response.text
assert "Hostnames" in response.text assert "Hostnames" in response.text
assert "Update events" 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 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: 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) 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']}") management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200 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 "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text assert "CNAME target:" in management.text
assert "Router fields" in management.text assert "Router fields" in management.text