Archived
Refactor UI assets and harden quality checks
This commit is contained in:
1 parent
23ab466c52
commit
4ecc8eb874
62 files changed
+3314
-3206
No files matched your search
+270
-114
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..security import admin_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .layout import PageOptions, _page, _top_nav
|
||||
from .templates_admin_tables import (
|
||||
_account_row,
|
||||
_admin_table_pagination,
|
||||
@@ -17,6 +18,52 @@ from .templates_admin_tables import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CleanupSummary:
|
||||
last_run: object
|
||||
next_run: object
|
||||
status: object
|
||||
last_error: object
|
||||
interval_seconds: object
|
||||
accounts_removed: object
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminPageContext:
|
||||
accounts: Sequence[Mapping[str, object]]
|
||||
cleanup_runs: Sequence[Mapping[str, object]]
|
||||
scheduled_tasks: Sequence[Mapping[str, object]]
|
||||
update_events: Sequence[Mapping[str, object]]
|
||||
service_metrics: dict[str, float]
|
||||
table_counts: dict[str, int]
|
||||
table_offsets: dict[str, int]
|
||||
table_limit: int
|
||||
notice_html: SafeHtml | None
|
||||
|
||||
|
||||
def _admin_page_context(
|
||||
store: DdnsStore,
|
||||
table_offsets: dict[str, int],
|
||||
table_limit: int,
|
||||
notice_html: SafeHtml | None,
|
||||
) -> AdminPageContext:
|
||||
return AdminPageContext(
|
||||
accounts=store.list_accounts(limit=table_limit, offset=table_offsets.get("accounts", 0)),
|
||||
cleanup_runs=store.list_cleanup_runs(limit=table_limit, offset=table_offsets.get("cleanup_runs", 0)),
|
||||
scheduled_tasks=store.list_scheduled_tasks(),
|
||||
update_events=store.list_update_events(table_limit, offset=table_offsets.get("update_events", 0)),
|
||||
service_metrics=store.get_service_metrics(),
|
||||
table_counts={
|
||||
"accounts": store.count_accounts(),
|
||||
"cleanup_runs": store.count_cleanup_runs(),
|
||||
"update_events": store.count_update_events(),
|
||||
},
|
||||
table_offsets=table_offsets,
|
||||
table_limit=table_limit,
|
||||
notice_html=notice_html,
|
||||
)
|
||||
|
||||
|
||||
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="section success-section">
|
||||
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
accounts: Sequence[Mapping[str, object]],
|
||||
cleanup_runs: Sequence[Mapping[str, object]],
|
||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||
update_events: Sequence[Mapping[str, object]],
|
||||
service_metrics: dict[str, float],
|
||||
table_counts: dict[str, int],
|
||||
table_offsets: dict[str, int],
|
||||
table_limit: int,
|
||||
notice_html: SafeHtml | None,
|
||||
context: AdminPageContext,
|
||||
) -> str:
|
||||
csrf = attr(admin_csrf_token(settings))
|
||||
admin_path = settings.admin_path
|
||||
safe_admin_path = attr(admin_path)
|
||||
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
||||
"""
|
||||
event_rows = "\n".join(_event_row(event) for event in update_events) or """
|
||||
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
||||
"""
|
||||
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
||||
event_count = escape(table_counts.get("update_events", len(update_events)))
|
||||
metrics_html = _metrics_panel(service_metrics)
|
||||
rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
|
||||
event_rows = _event_rows(context.update_events)
|
||||
accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
|
||||
event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
|
||||
metrics_html = _metrics_panel(context.service_metrics)
|
||||
account_pager = _server_page_links(
|
||||
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
|
||||
admin_path,
|
||||
"accounts_offset",
|
||||
context.table_offsets.get("accounts", 0),
|
||||
context.table_limit,
|
||||
context.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),
|
||||
context.table_offsets.get("update_events", 0),
|
||||
context.table_limit,
|
||||
context.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)
|
||||
cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
|
||||
totals_html = _totals_strip(context.table_counts, context.service_metrics)
|
||||
title_section = _admin_title_section(totals_html)
|
||||
status_section = _admin_status_section(cleanup_panel, metrics_html)
|
||||
hostnames_section = _admin_hostnames_section(context, rows, accounts_count, account_pager)
|
||||
events_section = _admin_events_section(context, event_rows, event_count, event_pager)
|
||||
return _page(
|
||||
"KarlDNS Admin",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav("Operator")}
|
||||
<section class="admin-console">
|
||||
<div class="container">
|
||||
<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="admin-block">
|
||||
<div class="container">
|
||||
{cleanup_panel}
|
||||
{metrics_html}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Hostnames</h2>
|
||||
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{account_pager}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Update events</h2>
|
||||
<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")}
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
||||
<tbody>{event_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</section>
|
||||
{title_section}
|
||||
{context.notice_html or ""}
|
||||
{status_section}
|
||||
{hostnames_section}
|
||||
{events_section}
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
||||
PageOptions(
|
||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=3" defer></script>'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _admin_title_section(totals_html: SafeHtml) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-console">
|
||||
<div class="container">
|
||||
<div class="admin-page-title">
|
||||
<div>
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
</div>
|
||||
{totals_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_status_section(cleanup_panel: SafeHtml, metrics_html: SafeHtml) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
{cleanup_panel}
|
||||
{metrics_html}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_hostnames_section(
|
||||
context: AdminPageContext,
|
||||
rows: SafeHtml,
|
||||
accounts_count: str,
|
||||
account_pager: SafeHtml,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Hostnames</h2>
|
||||
<p class="section-copy mb-0">{escape(len(context.accounts))} of {accounts_count} shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("hostnames", "Filter visible hostnames")}
|
||||
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Hostname</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">IPv4</th>
|
||||
<th scope="col">IPv6</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Links</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{account_pager}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _admin_events_section(
|
||||
context: AdminPageContext,
|
||||
event_rows: SafeHtml,
|
||||
event_count: str,
|
||||
event_pager: SafeHtml,
|
||||
) -> SafeHtml:
|
||||
return trusted(f"""
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<h2>Update events</h2>
|
||||
<p class="section-copy mb-0">
|
||||
{escape(len(context.update_events))} of {event_count} shown. Unchanged checks are grouped.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("update-events", "Filter visible update events")}
|
||||
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Hostname</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">IPv4</th>
|
||||
<th scope="col">IPv6</th>
|
||||
<th scope="col">Count</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{event_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def _account_rows(accounts: Sequence[Mapping[str, object]], *, csrf: str, admin_path: str) -> SafeHtml:
|
||||
return trusted("\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||
<tr class="empty-row">
|
||||
<td colspan="7" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">No hostnames yet.</div>
|
||||
<div class="empty-state-description">
|
||||
Generated hostnames appear here after users create or register them.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
|
||||
def _event_rows(events: Sequence[Mapping[str, object]]) -> SafeHtml:
|
||||
return trusted("\n".join(_event_row(event) for event in events) or """
|
||||
<tr class="empty-row">
|
||||
<td colspan="7" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">No update events yet.</div>
|
||||
<div class="empty-state-description">
|
||||
Router update activity and DNS publish results will appear here.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
|
||||
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
|
||||
items = [
|
||||
("Hostnames", table_counts.get("accounts", 0)),
|
||||
@@ -161,7 +285,7 @@ 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 class="alert {alert_class} ui-alert admin-notice" role="status" data-slot="alert">{escape(message)}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
|
||||
return trusted(f"""
|
||||
<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>
|
||||
<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>
|
||||
</nav>
|
||||
""")
|
||||
|
||||
@@ -193,37 +319,31 @@ def _cleanup_panel(
|
||||
safe_admin_path: str,
|
||||
csrf: str,
|
||||
) -> SafeHtml:
|
||||
task = next(
|
||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||
scheduled_tasks[0] if scheduled_tasks else {},
|
||||
summary = _cleanup_summary(
|
||||
cleanup_runs=cleanup_runs,
|
||||
scheduled_tasks=scheduled_tasks,
|
||||
fallback_interval_seconds=settings.cleanup_interval_seconds,
|
||||
)
|
||||
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
|
||||
next_run = task.get("next_run_at")
|
||||
status = task.get("last_status") or "pending"
|
||||
last_error = task.get("last_error") or ""
|
||||
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
||||
error_html = (
|
||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
||||
if last_error
|
||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
|
||||
if summary.last_error
|
||||
else trusted("")
|
||||
)
|
||||
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"))
|
||||
last_run_title = attr(str(summary.last_run or "Never"))
|
||||
next_run_title = attr(str(summary.next_run or "Pending"))
|
||||
last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
|
||||
next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
|
||||
return trusted(f"""
|
||||
<div class="admin-cleanup-bar">
|
||||
<div class="admin-cleanup-title">
|
||||
<h2>Cleanup</h2>
|
||||
<span>Every {escape(_format_interval(interval))}</span>
|
||||
<span>Every {escape(_format_interval(summary.interval_seconds))}</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>
|
||||
<div><dt>Status</dt><dd>{escape(str(summary.status))}</dd></div>
|
||||
<div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
|
||||
</dl>
|
||||
{error_html}
|
||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||
@@ -234,6 +354,39 @@ def _cleanup_panel(
|
||||
""")
|
||||
|
||||
|
||||
def _cleanup_task(scheduled_tasks: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
|
||||
return next(
|
||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||
scheduled_tasks[0] if scheduled_tasks else {},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_summary(
|
||||
*,
|
||||
cleanup_runs: Sequence[Mapping[str, object]],
|
||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||
fallback_interval_seconds: int,
|
||||
) -> _CleanupSummary:
|
||||
task = _cleanup_task(scheduled_tasks)
|
||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||
return _CleanupSummary(
|
||||
last_run=_cleanup_last_run(task, last_cleanup),
|
||||
next_run=task.get("next_run_at"),
|
||||
status=task.get("last_status") or "pending",
|
||||
last_error=task.get("last_error") or "",
|
||||
interval_seconds=task.get("interval_seconds") or fallback_interval_seconds,
|
||||
accounts_removed=_cleanup_accounts_removed(last_cleanup),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_last_run(task: Mapping[str, object], last_cleanup: Mapping[str, object]) -> object:
|
||||
return task.get("last_finished_at") or last_cleanup.get("created_at")
|
||||
|
||||
|
||||
def _cleanup_accounts_removed(last_cleanup: Mapping[str, object]) -> object:
|
||||
return last_cleanup.get("unused_accounts_deleted", 0)
|
||||
|
||||
|
||||
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
samples = metrics.get("update_latency_samples_total", 0)
|
||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||
@@ -247,7 +400,10 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
|
||||
(
|
||||
"Cleanup ok/errors",
|
||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
|
||||
(
|
||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/"
|
||||
f"{_metric_int(metrics.get('cleanup_errors_total', 0))}"
|
||||
),
|
||||
),
|
||||
]
|
||||
cards = "\n".join(f"""
|
||||
|
||||
Reference in new issue
Block a user