Archived
Simplify admin operator UI
This commit is contained in:
1 parent
0fc220b90b
commit
30aef1fee8
7 files changed
+386
-98
No files matched your search
+21
-10
@@ -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
@@ -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()
|
||||||
@@ -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">
|
||||||
|
<div class="admin-page-title">
|
||||||
|
<div>
|
||||||
<p class="eyebrow">KarlDNS</p>
|
<p class="eyebrow">KarlDNS</p>
|
||||||
<h1>Operator console</h1>
|
<h1>Operator console</h1>
|
||||||
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
|
</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,42 +199,28 @@ 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>
|
</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">
|
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||||
<input type="hidden" name="csrf" value="{csrf}">
|
<input type="hidden" name="csrf" value="{csrf}">
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
|
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3 mt-3">
|
|
||||||
{card_html}
|
|
||||||
</div>
|
|
||||||
</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")
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user