This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/web/templates_admin.py
T

252 lines
10 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from __future__ import annotations
from urllib.parse import urlencode
2026-05-21 17:23:08 +02:00
from ..security import admin_csrf_token
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
2026-05-20 23:55:27 +02:00
from .html_utils import SafeHtml, attr, escape, trusted
2026-05-21 17:23:08 +02:00
from .layout import _page, _top_nav
2026-05-20 23:55:27 +02:00
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_event_row,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Copy these values now. Secret values are only shown once.</p>
2026-05-20 23:55:27 +02:00
<div class="d-grid gap-2">
{"".join(rows)}
</div>
</div>
</section>
""")
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
2026-05-21 10:05:40 +02:00
service_metrics: dict[str, float],
2026-05-20 23:55:27 +02:00
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> 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)))
2026-05-21 10:05:40 +02:00
metrics_html = _metrics_panel(service_metrics)
2026-05-20 23:55:27 +02:00
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))
2026-05-21 10:05:40 +02:00
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
2026-05-20 23:55:27 +02:00
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
2026-05-21 17:23:08 +02:00
{_top_nav("Operator")}
2026-05-20 23:55:27 +02:00
<section class="section admin-heading">
<div class="container">
2026-05-21 10:05:40 +02:00
<p class="eyebrow">KarlDNS</p>
2026-05-20 23:55:27 +02:00
<h1>Operator console</h1>
2026-05-21 14:18:39 +02:00
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
2026-05-20 23:55:27 +02:00
</div>
</section>
{notice_html or ""}
<section class="section">
<div class="container">
2026-05-21 10:05:40 +02:00
<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>
2026-05-20 23:55:27 +02:00
</div>
2026-05-21 10:05:40 +02:00
{cleanup_panel}
<div class="mt-4">
{metrics_html}
2026-05-20 23:55:27 +02:00
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Hostnames</p>
2026-05-21 10:05:40 +02:00
<h2>Hostnames</h2>
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
2026-05-20 23:55:27 +02:00
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
2026-05-21 17:23:08 +02:00
<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>
2026-05-20 23:55:27 +02:00
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Updates</p>
2026-05-21 10:05:40 +02:00
<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>
2026-05-20 23:55:27 +02:00
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
2026-05-21 14:18:39 +02:00
<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>
2026-05-20 23:55:27 +02:00
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
</main>
"""),
2026-05-21 10:05:40 +02:00
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
2026-05-20 23:55:27 +02:00
)
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit:
return trusted("")
previous_offset = max(0, offset - limit)
next_offset = offset + limit
previous_disabled = " disabled" if offset <= 0 else ""
next_disabled = " disabled" if next_offset >= total else ""
previous_href = attr(f"{admin_path}?{urlencode({offset_name: previous_offset})}")
next_href = attr(f"{admin_path}?{urlencode({offset_name: next_offset})}")
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">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
2026-05-21 10:05:40 +02:00
<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>
2026-05-20 23:55:27 +02:00
</nav>
""")
2026-05-21 10:05:40 +02:00
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
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 {})
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
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
)
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>
2026-05-21 14:18:39 +02:00
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
2026-05-21 10:05:40 +02:00
{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>
""")
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
items = [
("Updates", _metric_int(metrics.get("update_requests_total", 0))),
("Updated", _metric_int(metrics.get("update_success_total", 0))),
("DNS failures", _metric_int(metrics.get("dns_failures_total", 0))),
("DNS check failures", _metric_int(metrics.get("dns_reconcile_failures_total", 0))),
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
("Average update time", f"{average_latency:.1f} ms"),
("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))}"),
]
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>
"""
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>
""")
def _metric_int(value: float) -> str:
return str(int(value))