2026-05-20 23:55:27 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
from collections.abc import Mapping, Sequence
|
2026-05-21 22:26:14 +02:00
|
|
|
from datetime import datetime
|
2026-05-20 23:55:27 +02:00
|
|
|
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>
|
|
|
|
|
""")
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
def _render_admin_page(
|
|
|
|
|
service: DdnsService,
|
|
|
|
|
settings: DdnsSettings,
|
2026-05-22 17:05:06 +02:00
|
|
|
accounts: Sequence[Mapping[str, object]],
|
|
|
|
|
cleanup_runs: Sequence[Mapping[str, object]],
|
|
|
|
|
scheduled_tasks: Sequence[Mapping[str, object]],
|
|
|
|
|
update_events: Sequence[Mapping[str, object]],
|
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-22 17:05:06 +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-21 22:26:14 +02:00
|
|
|
totals_html = _totals_strip(table_counts, service_metrics)
|
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-21 22:26:14 +02:00
|
|
|
<section class="admin-console">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div class="container">
|
2026-05-21 22:26:14 +02:00
|
|
|
<div class="admin-page-title">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="eyebrow">KarlDNS</p>
|
|
|
|
|
<h1>Operator console</h1>
|
|
|
|
|
</div>
|
|
|
|
|
{totals_html}
|
|
|
|
|
</div>
|
2026-05-20 23:55:27 +02:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{notice_html or ""}
|
|
|
|
|
|
2026-05-21 22:26:14 +02:00
|
|
|
<section class="admin-block">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div class="container">
|
2026-05-21 10:05:40 +02:00
|
|
|
{cleanup_panel}
|
2026-05-21 22:26:14 +02:00
|
|
|
{metrics_html}
|
2026-05-20 23:55:27 +02:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-21 22:26:14 +02:00
|
|
|
<section class="admin-block">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div class="container">
|
2026-05-21 22:26:14 +02:00
|
|
|
<div class="admin-section-heading">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div>
|
2026-05-21 10:05:40 +02:00
|
|
|
<h2>Hostnames</h2>
|
2026-05-21 22:26:14 +02:00
|
|
|
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</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>
|
|
|
|
|
|
2026-05-21 22:26:14 +02:00
|
|
|
<section class="admin-block">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div class="container">
|
2026-05-21 22:26:14 +02:00
|
|
|
<div class="admin-section-heading">
|
2026-05-20 23:55:27 +02:00
|
|
|
<div>
|
2026-05-21 10:05:40 +02:00
|
|
|
<h2>Update events</h2>
|
2026-05-21 22:26:14 +02:00
|
|
|
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks 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-22 16:30:37 +02:00
|
|
|
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
2026-05-21 10:05:40 +02:00
|
|
|
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
2026-05-20 23:55:27 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:26:14 +02:00
|
|
|
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">'
|
2026-05-22 17:05:06 +02:00
|
|
|
+ "".join(f"<span><strong>{escape(value)}</strong>{escape(label)}</span>" for label, value in items)
|
2026-05-21 22:26:14 +02:00
|
|
|
+ "</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>
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
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"""
|
2026-05-21 22:26:14 +02:00
|
|
|
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
|
2026-05-20 23:55:27 +02:00
|
|
|
<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,
|
2026-05-22 17:05:06 +02:00
|
|
|
cleanup_runs: Sequence[Mapping[str, object]],
|
|
|
|
|
scheduled_tasks: Sequence[Mapping[str, object]],
|
2026-05-21 10:05:40 +02:00
|
|
|
safe_admin_path: str,
|
|
|
|
|
csrf: str,
|
|
|
|
|
) -> SafeHtml:
|
2026-05-22 17:05:06 +02:00
|
|
|
task = next(
|
|
|
|
|
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
|
|
|
|
scheduled_tasks[0] if scheduled_tasks else {},
|
|
|
|
|
)
|
2026-05-21 10:05:40 +02:00
|
|
|
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("")
|
|
|
|
|
)
|
2026-05-21 22:26:14 +02:00
|
|
|
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"))
|
2026-05-21 10:05:40 +02:00
|
|
|
return trusted(f"""
|
2026-05-21 22:26:14 +02:00
|
|
|
<div class="admin-cleanup-bar">
|
|
|
|
|
<div class="admin-cleanup-title">
|
|
|
|
|
<h2>Cleanup</h2>
|
|
|
|
|
<span>Every {escape(_format_interval(interval))}</span>
|
2026-05-21 10:05:40 +02:00
|
|
|
</div>
|
2026-05-21 22:26:14 +02:00
|
|
|
<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>
|
2026-05-21 10:05:40 +02:00
|
|
|
</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"),
|
2026-05-22 17:05:06 +02:00
|
|
|
(
|
|
|
|
|
"Cleanup ok/errors",
|
|
|
|
|
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
|
|
|
|
|
),
|
2026-05-21 10:05:40 +02:00
|
|
|
]
|
2026-05-22 17:05:06 +02:00
|
|
|
cards = "\n".join(f"""
|
2026-05-21 22:26:14 +02:00
|
|
|
<span><strong>{escape(value)}</strong>{escape(label)}</span>
|
2026-05-22 17:05:06 +02:00
|
|
|
""" for label, value in items)
|
2026-05-21 10:05:40 +02:00
|
|
|
return trusted(f"""
|
2026-05-21 22:26:14 +02:00
|
|
|
<div class="admin-metric-strip" aria-label="Service metrics">
|
|
|
|
|
{cards}
|
2026-05-21 10:05:40 +02:00
|
|
|
</div>
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _metric_int(value: object) -> str:
|
|
|
|
|
try:
|
|
|
|
|
return str(int(float(str(value))))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return "0"
|
2026-05-21 22:26:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_interval(seconds: object) -> str:
|
|
|
|
|
try:
|
2026-05-22 17:05:06 +02:00
|
|
|
value = int(str(seconds))
|
2026-05-21 22:26:14 +02:00
|
|
|
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")
|