Archived
Simplify admin operator UI
This commit is contained in:
1 parent
0fc220b90b
commit
30aef1fee8
7 files changed
+393
-105
No files matched your search
+208
-8
@@ -1091,6 +1091,131 @@ select:focus-visible {
|
||||
|
||||
|
||||
ADMIN_CSS = """
|
||||
.admin-console {
|
||||
padding: 32px 0 18px;
|
||||
background: var(--rp-canvas);
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.admin-page-title h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.admin-total-strip,
|
||||
.admin-metric-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-total-strip span,
|
||||
.admin-metric-strip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--rp-line);
|
||||
border-radius: 999px;
|
||||
background: var(--rp-surface);
|
||||
color: var(--rp-muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-total-strip strong,
|
||||
.admin-metric-strip strong {
|
||||
color: var(--rp-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-block {
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.admin-block + .admin-block {
|
||||
border-top: 1px solid var(--rp-soft-line);
|
||||
}
|
||||
|
||||
.admin-section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-notice {
|
||||
margin: 10px 0 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.admin-cleanup-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 2.3fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--rp-soft-line);
|
||||
}
|
||||
|
||||
.admin-cleanup-title {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-cleanup-title h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-cleanup-title span {
|
||||
color: var(--rp-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-cleanup-times {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-cleanup-times div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-cleanup-times dt {
|
||||
margin: 0;
|
||||
color: var(--rp-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-cleanup-times dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--rp-ink);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-cleanup-bar form {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.admin-metric-strip {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -1113,7 +1238,7 @@ ADMIN_CSS = """
|
||||
}
|
||||
|
||||
.admin-table-scroll {
|
||||
max-height: 560px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
background: var(--rp-surface);
|
||||
border-color: var(--rp-line) !important;
|
||||
@@ -1161,8 +1286,9 @@ ADMIN_CSS = """
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--rp-ink);
|
||||
}
|
||||
@@ -1178,15 +1304,13 @@ table {
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--rp-soft-line);
|
||||
padding: 14px 10px;
|
||||
padding: 10px 9px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
td {
|
||||
max-width: 38rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status-table {
|
||||
@@ -1209,6 +1333,51 @@ th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--rp-surface-alt);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-hostname {
|
||||
width: 16rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.cell-ip {
|
||||
width: 10rem;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.cell-time {
|
||||
width: 13rem;
|
||||
max-width: 13rem;
|
||||
}
|
||||
|
||||
.cell-status,
|
||||
.cell-count,
|
||||
.cell-delete {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-detail {
|
||||
min-width: 18rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
.admin-action-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-action-group .btn,
|
||||
.cell-delete .btn {
|
||||
min-height: 34px;
|
||||
padding-inline: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -1218,6 +1387,37 @@ th {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.admin-console {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.admin-page-title,
|
||||
.admin-section-heading {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-cleanup-bar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-cleanup-times {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-cleanup-bar form {
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-cleanup-bar button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table-shell .form-control {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -1233,7 +1433,7 @@ th {
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 9px;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
}
|
||||
""".strip()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..security import admin_csrf_token
|
||||
@@ -57,43 +58,39 @@ def _render_admin_page(
|
||||
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"""
|
||||
<main id="main">
|
||||
{_top_nav("Operator")}
|
||||
<section class="section admin-heading">
|
||||
<section class="admin-console">
|
||||
<div class="container">
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
|
||||
<div class="admin-page-title">
|
||||
<div>
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
</div>
|
||||
{totals_html}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice_html or ""}
|
||||
|
||||
<section class="section">
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
|
||||
<div>
|
||||
<p class="eyebrow">Service health</p>
|
||||
<h2>Service status</h2>
|
||||
</div>
|
||||
</div>
|
||||
{cleanup_panel}
|
||||
<div class="mt-4">
|
||||
{metrics_html}
|
||||
</div>
|
||||
{metrics_html}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Hostnames</p>
|
||||
<h2>Hostnames</h2>
|
||||
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
|
||||
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
||||
@@ -110,13 +107,12 @@ def _render_admin_page(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<section class="admin-block">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||
<div class="admin-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Updates</p>
|
||||
<h2>Update events</h2>
|
||||
<p class="section-copy mb-0">Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.</p>
|
||||
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.</p>
|
||||
</div>
|
||||
</div>
|
||||
{_admin_table_toolbar("update-events", "Search update events")}
|
||||
@@ -132,11 +128,37 @@ def _render_admin_page(
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=3" rel="stylesheet">'),
|
||||
extra_styles=trusted('<link href="/admin.css?v=4" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
'<div class="admin-total-strip" aria-label="Service totals">'
|
||||
+ "".join(
|
||||
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
|
||||
for label, value in items
|
||||
)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
|
||||
def _admin_notice(kind: str, message: str) -> SafeHtml:
|
||||
alert_class = "alert-success" if kind == "success" else "alert-info"
|
||||
return trusted(f"""
|
||||
<div class="container">
|
||||
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
|
||||
if total <= limit:
|
||||
return trusted("")
|
||||
@@ -149,7 +171,7 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
|
||||
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">
|
||||
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" 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</a>
|
||||
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
||||
@@ -177,41 +199,27 @@ def _cleanup_panel(
|
||||
if last_error
|
||||
else trusted("")
|
||||
)
|
||||
cards = [
|
||||
("Interval", f"*/{interval}s"),
|
||||
("Last run", str(last_run or "Never")),
|
||||
("Next run", str(next_run or "Pending")),
|
||||
("Status", str(status)),
|
||||
]
|
||||
card_html = "\n".join(
|
||||
f"""
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="metric-card h-100">
|
||||
<span>{escape(label)}</span>
|
||||
<strong>{escape(value)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
for label, value in cards
|
||||
)
|
||||
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"""
|
||||
<div class="card flat-card">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
|
||||
<div>
|
||||
<h3 class="h5 mb-2">Cleanup</h3>
|
||||
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
|
||||
{error_html}
|
||||
</div>
|
||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||
<input type="hidden" name="csrf" value="{csrf}">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row g-3 mt-3">
|
||||
{card_html}
|
||||
</div>
|
||||
<div class="admin-cleanup-bar">
|
||||
<div class="admin-cleanup-title">
|
||||
<h2>Cleanup</h2>
|
||||
<span>Every {escape(_format_interval(interval))}</span>
|
||||
</div>
|
||||
<dl class="admin-cleanup-times">
|
||||
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
|
||||
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
|
||||
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
|
||||
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
|
||||
</dl>
|
||||
{error_html}
|
||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||
<input type="hidden" name="csrf" value="{csrf}">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
|
||||
</form>
|
||||
</div>
|
||||
""")
|
||||
|
||||
@@ -231,22 +239,41 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
]
|
||||
cards = "\n".join(
|
||||
f"""
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="metric-card h-100">
|
||||
<span>{escape(label)}</span>
|
||||
<strong>{escape(value)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span><strong>{escape(value)}</strong>{escape(label)}</span>
|
||||
"""
|
||||
for label, value in items
|
||||
)
|
||||
return trusted(f"""
|
||||
<div class="mt-4">
|
||||
<p class="eyebrow">Metrics</p>
|
||||
<div class="row g-3 mt-1">{cards}</div>
|
||||
<div class="admin-metric-strip" aria-label="Service metrics">
|
||||
{cards}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _metric_int(value: float) -> str:
|
||||
return str(int(value))
|
||||
|
||||
|
||||
def _format_interval(seconds: object) -> str:
|
||||
try:
|
||||
value = int(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")
|
||||
@@ -1,32 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from .components import _event_label
|
||||
from .html_utils import attr, escape
|
||||
|
||||
|
||||
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
raw_hostname = str(account["hostname"])
|
||||
hostname = escape(raw_hostname)
|
||||
hostname_attr = attr(raw_hostname)
|
||||
status = "Active" if account.get("accepted_at") else "Pending"
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
ipv4_raw = str(account.get("ipv4") or "-")
|
||||
ipv6_raw = str(account.get("ipv6") or "-")
|
||||
updated_raw = str(account.get("updated_at") or "Never")
|
||||
ipv4 = escape(ipv4_raw)
|
||||
ipv6 = escape(ipv6_raw)
|
||||
updated = escape(_short_time(updated_raw, "Never"))
|
||||
safe_admin_path = attr(admin_path)
|
||||
return f"""
|
||||
<tr>
|
||||
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="d-flex gap-2 flex-wrap">
|
||||
<td class="cell-hostname" title="{hostname_attr}">{hostname}</td>
|
||||
<td class="cell-status">{escape(status)}</td>
|
||||
<td class="cell-ip" title="{attr(ipv4_raw)}">{ipv4}</td>
|
||||
<td class="cell-ip" title="{attr(ipv6_raw)}">{ipv6}</td>
|
||||
<td class="cell-time" title="{attr(updated_raw)}">{updated}</td>
|
||||
<td class="cell-actions">
|
||||
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
|
||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<td class="cell-delete">
|
||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
|
||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -67,11 +78,29 @@ def _admin_table_pagination() -> str:
|
||||
|
||||
|
||||
def _event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
hostname = escape(event.get("hostname") or "-")
|
||||
created_raw = str(event.get("created_at") or "-")
|
||||
hostname_raw = str(event.get("hostname") or "-")
|
||||
detail_raw = str(event.get("detail") or "-")
|
||||
created = escape(_short_time(created_raw))
|
||||
hostname = escape(hostname_raw)
|
||||
status = escape(_event_label(event.get("status")))
|
||||
ipv4 = escape(event.get("ipv4") or "-")
|
||||
ipv6 = escape(event.get("ipv6") or "-")
|
||||
count = escape(event.get("count") or 1)
|
||||
detail = escape(event.get("detail") or "-")
|
||||
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
|
||||
detail = escape(detail_raw)
|
||||
return (
|
||||
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
|
||||
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
|
||||
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
|
||||
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
|
||||
)
|
||||
|
||||
|
||||
def _short_time(value: str, fallback: str = "-") -> str:
|
||||
if not value or value == fallback:
|
||||
return fallback
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return parsed.strftime("%Y-%m-%d %H:%M")
|
||||
@@ -142,7 +142,7 @@ def _render_management_page(
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=3" rel="stylesheet">'),
|
||||
extra_styles=trusted('<link href="/admin.css?v=4" rel="stylesheet">'),
|
||||
)
|
||||
|
||||
def _render_pending_management_page(
|
||||
|
||||
Reference in new issue
Block a user