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"""
""")
def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
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"""
IPv4 {view.ipv4}
IPv6 {view.ipv6}
Updated {view.updated}
""")
def _manual_updates_card(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
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"""
Time Status IPv4 IPv6 Count Detail
{history_rows}
""")
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"""
""")
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 = """
Current password
"""
return trusted(f"""
Dashboard password
Password protection
Status: {escape(password_status)}. This password protects only this dashboard.
Router updates still work.
""")
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'{escape(message)}
'
)
return _page(
"Unlock KarlDNS management",
trusted(f"""
{_top_nav()}
Dashboard locked
{hostname}
Enter the dashboard password for this hostname.
"""),
)
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"