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/router_dyndns/web/templates_admin.py
T

234 lines
11 KiB
Python

from __future__ import annotations
from urllib.parse import urlencode
from ..ddns_models import DdnsSettings
from ..ddns_security import admin_csrf_token
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_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>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<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]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
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)
safe_records_path = attr(f"{admin_path}/records")
safe_events_path = attr(f"{admin_path}/events")
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>
"""
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks 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)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
"domain_claims_offset",
table_offsets.get("domain_challenges", 0),
table_limit,
table_counts.get("domain_challenges", 0),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 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))
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
</div>
</section>
{notice_html or ""}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
</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>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</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>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
</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">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{domain_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">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_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>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</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>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
</main>
"""),
)
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>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
</nav>
""")