from __future__ import annotations
from collections.abc import Mapping, Sequence
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 .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
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"""
Admin action
{escape(title)}
Copy these values now. Secret values are only shown once.
{"".join(rows)}
""")
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,
) -> 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 """
| No hostnames yet. |
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
| No update events yet. |
"""
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)
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),
)
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
return _page(
"KarlDNS Admin",
trusted(f"""
{_top_nav("Operator")}
{notice_html or ""}
{cleanup_panel}
{metrics_html}
Hostnames
{escape(len(accounts))} of {accounts_count} shown.
{_admin_table_toolbar("hostnames", "Search hostnames")}
{_admin_table_pagination()}
{account_pager}
Update events
{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.
{_admin_table_toolbar("update-events", "Search update events")}
{_admin_table_pagination()}
{event_pager}
"""),
extra_styles=trusted(''),
extra_scripts=trusted(''),
)
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(
''
+ "".join(f"{escape(value)}{escape(label)}" for label, value in items)
+ "
"
)
def _admin_notice(kind: str, message: str) -> SafeHtml:
alert_class = "alert-success" if kind == "success" else "alert-info"
return trusted(f"""
""")
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"""
""")
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
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'Last error: {escape(last_error)}
')
if 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"))
return trusted(f"""
Cleanup
Every {escape(_format_interval(interval))}
- Last run
- {last_run_display}
- Next run
- {next_run_display}
- Status
- {escape(str(status))}
- Removed
- {escape(accounts_removed)}
{error_html}
""")
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"""
{escape(value)}{escape(label)}
""" for label, value in items)
return trusted(f"""
{cards}
""")
def _metric_int(value: object) -> str:
try:
return str(int(float(str(value))))
except ValueError:
return "0"
def _format_interval(seconds: object) -> str:
try:
value = int(str(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")