From 3ce992ae9e885fccbea16f9e26f1b51cd37482a7 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 18:56:09 +0200 Subject: [PATCH] Add admin table search and pagination --- router_dyndns/ddns.py | 146 +++++++++++++++++++++++++++++++++++++++--- tests/test_ddns.py | 5 ++ 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index 10a7928..a2bd2f3 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -85,6 +85,92 @@ document.addEventListener("click", async (event) => { }, 1200); } catch (_) {} }); + +document.querySelectorAll("[data-admin-table]").forEach((tableShell) => { + const table = tableShell.querySelector("table"); + const tbody = tableShell.querySelector("tbody"); + const search = tableShell.querySelector("[data-table-search]"); + const pageSize = tableShell.querySelector("[data-table-page-size]"); + const count = tableShell.querySelector("[data-table-count]"); + const pagination = tableShell.querySelector("[data-table-pagination]"); + if (!table || !tbody || !search || !pageSize || !count || !pagination) return; + + const headerCount = table.querySelectorAll("thead th").length || 1; + const originalRows = Array.from(tbody.querySelectorAll("tr")); + const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row")); + let currentPage = 1; + + const emptyRow = (message) => { + const row = document.createElement("tr"); + row.className = "empty-row"; + const cell = document.createElement("td"); + cell.className = "empty"; + cell.colSpan = headerCount; + cell.textContent = message; + row.append(cell); + return row; + }; + + const visibleRows = () => { + const query = search.value.trim().toLowerCase(); + if (!query) return dataRows; + return dataRows.filter((row) => row.textContent.toLowerCase().includes(query)); + }; + + const renderButton = (label, page, disabled = false, active = false) => { + const item = document.createElement("li"); + item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`; + const button = document.createElement("button"); + button.type = "button"; + button.className = "page-link"; + button.textContent = label; + button.disabled = disabled; + button.addEventListener("click", () => { + currentPage = page; + render(); + }); + item.append(button); + return item; + }; + + const renderPagination = (totalPages) => { + pagination.replaceChildren(); + if (totalPages <= 1) return; + pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1)); + for (let page = 1; page <= totalPages; page += 1) { + if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) { + if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true)); + continue; + } + pagination.append(renderButton(String(page), page, false, page === currentPage)); + } + pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages)); + }; + + const render = () => { + const rows = visibleRows(); + const size = Number.parseInt(pageSize.value, 10) || 25; + const totalPages = Math.max(1, Math.ceil(rows.length / size)); + currentPage = Math.min(currentPage, totalPages); + const start = (currentPage - 1) * size; + const pageRows = rows.slice(start, start + size); + tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")])); + const shownStart = rows.length ? start + 1 : 0; + const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0; + count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`; + renderPagination(totalPages); + }; + + search.addEventListener("input", () => { + currentPage = 1; + render(); + }); + pageSize.addEventListener("change", () => { + currentPage = 1; + render(); + }); + render(); +}); """.strip() @@ -883,16 +969,16 @@ def _render_admin_page( ) -> str: csrf = html.escape(admin_csrf_token(settings)) rows = "\n".join(_account_row(account, csrf=csrf) for account in accounts) or """ - No hostnames yet. + No hostnames yet. """ domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """ - No domain claims yet. + No domain claims yet. """ cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """ - No cleanup runs yet. + No cleanup runs yet. """ event_rows = "\n".join(_event_row(event) for event in update_events) or """ - No update events yet. + No update events yet. """ return _page( "DynDNS Admin", @@ -933,7 +1019,8 @@ def _render_admin_page(

Active credentials

-
+ {_admin_table_toolbar("hostnames", "Search hostnames")} +
@@ -941,6 +1028,7 @@ def _render_admin_page( {rows}
DomainUserIPv4IPv6UpdatedLinks
+ {_admin_table_pagination()}
@@ -952,12 +1040,14 @@ def _render_admin_page(

Verification lifecycle

-
+ {_admin_table_toolbar("domain-claims", "Search domain claims")} +
{domain_rows}
DomainStatusCreatedVerified
+ {_admin_table_pagination()}
@@ -969,12 +1059,14 @@ def _render_admin_page(

Recent runs

-
+ {_admin_table_toolbar("cleanup-runs", "Search cleanup runs")} +
{cleanup_rows}
TimeDomain claims removedUnused hostnames removed
+ {_admin_table_pagination()}
@@ -986,12 +1078,14 @@ def _render_admin_page(

Recent events

-
+ {_admin_table_toolbar("update-events", "Search update events")} +
{event_rows}
TimeDomainStatusIPv4IPv6Detail
+ {_admin_table_pagination()}
@@ -1028,6 +1122,39 @@ def _account_row(account: dict[str, str | int | None], csrf: str = "") -> str: """ +def _admin_table_toolbar(table_id: str, search_label: str) -> str: + safe_id = html.escape(table_id) + safe_label = html.escape(search_label) + return f""" +
+
+ +
+ 0-0 of 0 + + +
+
+ """ + + +def _admin_table_pagination() -> str: + return """ + +
+ """ + + def _domain_claim_row(challenge: dict[str, str | None]) -> str: domain = html.escape(str(challenge["domain"])) created = html.escape(str(challenge.get("created_at") or "-")) @@ -1158,6 +1285,9 @@ def _page(title: str, body: str) -> str: .copy-field .input-group-text {{ min-width: 150px; color: var(--rp-muted); font-size: 13px; }} .copy-field code {{ min-height: 44px; margin: 0; color: var(--rp-ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.6; background: var(--bs-body-bg); }} .section-dark .copy-field code {{ color: #f5f5f7; background: #1d1d1f; }} + .admin-table-scroll {{ max-height: 560px; overflow: auto; }} + .admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }} + .table-page-size {{ width: auto; min-width: 76px; }} table {{ width: 100%; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; color: var(--rp-ink); }} th, td {{ border-bottom: 1px solid var(--rp-soft-line); padding: 14px 10px; text-align: left; white-space: nowrap; }} tr:last-child th, tr:last-child td {{ border-bottom: 0; }} diff --git a/tests/test_ddns.py b/tests/test_ddns.py index f3c98a4..011de9c 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -166,6 +166,11 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> assert "Operator console" in response.text assert "Generate credentials" not in response.text assert 'action="/admin/accounts"' not in response.text + assert response.text.count("data-admin-table") == 4 + assert response.text.count("data-table-search") == 4 + assert response.text.count("data-table-page-size") == 4 + assert response.text.count("data-table-pagination") == 4 + assert "admin-table-scroll" in response.text assert "Scheduled cleanup" in response.text assert "Active credentials" in response.text assert "Domain claims" in response.text