from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from typing import TYPE_CHECKING from ..services.ddns import DdnsService from .components import ( _code_window, _copy_button, _copy_row, _event_label, _fritzbox_fields, _manual_update_commands, _secret_notice, ) from .html_utils import SafeHtml, attr, escape, trusted from .layout import PageOptions, _page, _top_nav if TYPE_CHECKING: from ..settings import DdnsSettings @dataclass(frozen=True) class ManagementPageContext: management_slug: str account: Mapping[str, object] csrf_token: str message: str | None = None request_ip: str | None = None @property def account_for_url(self) -> dict[str, str]: return { "hostname": str(self.account["hostname"]), "update_slug": str(self.account["update_slug"]), "management_slug": self.management_slug, } @dataclass(frozen=True, slots=True) class _ManagementPageView: update_url: str hostname: str ipv4: str ipv6: str updated: str fritzbox_fields: str cname_record: str manual_commands: str def _management_page_view(update_url: str, context: ManagementPageContext) -> _ManagementPageView: hostname = escape(context.account["hostname"]) return _ManagementPageView( update_url=update_url, hostname=hostname, ipv4=escape(context.account.get("ipv4") or "-"), ipv6=escape(context.account.get("ipv6") or "-"), updated=escape(context.account.get("updated_at") or "Never"), fritzbox_fields=_fritzbox_fields(update_url, hostname), cname_record=f"your-subdomain.example.com. CNAME {hostname}.", manual_commands=_manual_update_commands(update_url, context.request_ip), ) @dataclass(frozen=True) class PendingManagementPageContext: settings: DdnsSettings management_slug: str account: Mapping[str, object] csrf_token: str message: str | None = None request_ip: str | None = None @property def account_for_url(self) -> dict[str, str]: return { "hostname": str(self.account["hostname"]), "update_slug": str(self.account["update_slug"]), "management_slug": self.management_slug, } def _render_management_page(service: DdnsService, context: ManagementPageContext) -> str: update_url = service.fritz_update_url(context.account_for_url) view = _management_page_view(update_url, context) message_html = _management_message(context.message) dashboard_password_panel = _dashboard_password_panel( context.management_slug, context.csrf_token, bool(context.account.get("dashboard_password_hash")), ) dashboard_shell = _management_dashboard_shell(context, view, message_html) history_section = _management_history_section(_management_history_rows(service, context)) delete_section = _management_delete_section(context) return _page( "KarlDNS management", trusted(f"""
{_top_nav()} {dashboard_shell} {dashboard_password_panel} {history_section} {delete_section}
"""), PageOptions(extra_styles=trusted('')), ) def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml: events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10) rows = "\n".join(_user_event_row(event) for event in events) return trusted(rows or """
No router updates yet.
This table fills after the router or a script sends its first address update.
""") def _management_dashboard_shell( context: ManagementPageContext, view: _ManagementPageView, message_html: SafeHtml, ) -> SafeHtml: return trusted(f"""
{_management_dashboard_header(view)} {message_html}
{_router_settings_card(context, view)} {_status_card(view)}
{_manual_updates_card(view)}
""") def _management_dashboard_header(view: _ManagementPageView) -> SafeHtml: return trusted(f"""

Dashboard

{view.hostname}

Private management link. Keep it safe.

Home
""") def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml: return trusted(f"""

Router settings

Update endpoint

Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.

{_secret_notice()}
{_copy_row("Update-URL:", view.update_url, "Secret")} {_copy_row("Domainnamen:", view.hostname, "Public")} {_copy_row("CNAME target:", view.hostname, "Public")}
{_copy_button("Router fields", view.fritzbox_fields)} {_copy_button("Copy CNAME", view.cname_record, style="outline-secondary")}
""") def _status_card(view: _ManagementPageView) -> SafeHtml: return trusted(f"""

Current address

Status

IPv4
{view.ipv4}
IPv6
{view.ipv6}
Updated
{view.updated}
""") def _manual_updates_card(view: _ManagementPageView) -> SafeHtml: return trusted(f"""

Manual updates

curl examples

FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.

{_code_window(view.manual_commands, copy_label="manual update commands", class_name="manual-code")}
""") def _management_history_section(history_rows: SafeHtml) -> SafeHtml: return trusted(f"""

History

Router updates

Recent IP changes and failed updates for this hostname.

{history_rows}
TimeStatusIPv4IPv6CountDetail
""") def _management_delete_section(context: ManagementPageContext) -> SafeHtml: return trusted(f"""

Delete

Delete hostname

Remove this hostname and its DNS records. The update URL will stop working.

""") def _render_pending_management_page(service: DdnsService, context: PendingManagementPageContext) -> str: raw_hostname = str(context.account["hostname"]) update_url = service.fritz_update_url(context.account_for_url) dashboard_url = service.dashboard_url({"management_slug": context.management_slug}) hostname = escape(raw_hostname) message_html = _management_message(context.message) cleanup_hours = context.settings.cleanup_pending_account_hours cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours" pending_lead = ( "Save these values, then register the hostname. Pending setups are cleaned up after " f"{cleanup_text}." ) manual_commands = _manual_update_commands(update_url, context.request_ip) hero = _pending_hero(hostname, pending_lead) values_section = _pending_values_section(context, update_url, raw_hostname, dashboard_url) router_section = _pending_router_section(hostname, update_url, manual_commands) return _page( "KarlDNS setup", trusted(f"""
{_top_nav()} {hero} {message_html} {values_section} {router_section}
"""), ) def _pending_hero(hostname: str, pending_lead: str) -> SafeHtml: return trusted(f"""

Pending setup

{hostname}

{escape(pending_lead)}

""") def _pending_values_section( context: PendingManagementPageContext, update_url: str, raw_hostname: str, dashboard_url: str, ) -> SafeHtml: return trusted(f"""

Save

Your values.

Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.

Public: Domainnamen. Secret: Update-URL and private dashboard.

{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)} {_copy_row("Domainnamen:", raw_hostname, "Public")} {_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
""") def _pending_router_section(hostname: str, update_url: str, manual_commands: str) -> SafeHtml: return trusted(f"""

FRITZ!Box

Router setup.

Paste this exact URL into the FRITZ!Box Update-URL field. Use {hostname} as Domainnamen. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.

{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}

Manual updates.

Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.

{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}

Use your own DNS name.

Create a CNAME at your DNS provider that points to {hostname}. This is the same value as Domainnamen.

""") def _management_message(message: str | None) -> SafeHtml: if not message: return trusted("") return trusted(f"""
{escape(message)}
""") def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml: password_status = "Enabled" if password_enabled else "Not enabled" password_action = "Change password" if password_enabled else "Set password" remove_form = "" if password_enabled: remove_form = f"""
""" current_password_field = "" if password_enabled: current_password_field = """
""" return trusted(f"""

Dashboard password

Password protection

Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.

{current_password_field}
{remove_form}
""") def _render_management_unlock_page( management_slug: str, account: Mapping[str, object], csrf_token: str, message: str | None = None, ) -> str: hostname = escape(account["hostname"]) message_html = "" if message: message_html = ( f'' ) return _page( "Unlock KarlDNS management", trusted(f"""
{_top_nav()}

Dashboard locked

{hostname}

Enter the dashboard password for this hostname.

{message_html}
"""), ) def _user_event_row(event: Mapping[str, object]) -> str: row = _user_event_row_view(event) return ( f'{row.created}{row.status}' f"{row.ipv4}{row.ipv6}{row.count}{row.detail}" ) @dataclass(frozen=True, slots=True) class _UserEventRow: created: str status: str badge_class: str ipv4: str ipv6: str count: str detail: str def _user_event_row_view(event: Mapping[str, object]) -> _UserEventRow: raw_status = str(event.get("status") or "") return _UserEventRow( created=escape(_event_text(event, "created_at")), status=escape(_event_label(raw_status)), badge_class=_event_badge_class(raw_status), ipv4=escape(_event_text(event, "ipv4")), ipv6=escape(_event_text(event, "ipv6")), count=escape(event.get("count") or 1), detail=escape(_event_text(event, "detail")), ) def _event_text(event: Mapping[str, object], key: str) -> str: return str(event.get(key) or "-") def _event_badge_class(status: str) -> str: if status in {"updated", "dashboard_hostname_activated", "unchanged"}: return "status-badge-ok" if "failed" in status or "failure" in status: return "status-badge-error" return "status-badge-neutral"