Archived
Refine management dashboard layout
This commit is contained in:
1 parent
c19e5170a0
commit
b9ec4e57ff
2 files changed
+352
-63
No files matched your search
@@ -1323,6 +1323,287 @@ td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
background: var(--rp-canvas);
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 32px 0 18px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
margin-top: 4px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.dashboard-page .lead {
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-page .section-copy {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dashboard-page .alert {
|
||||
margin: 0 0 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.75fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--rp-line);
|
||||
border-radius: 12px;
|
||||
background: var(--rp-surface);
|
||||
}
|
||||
|
||||
.dashboard-card-main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-card-compact {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dashboard-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-card-header h2,
|
||||
.dashboard-section h2 {
|
||||
margin-top: 2px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-copy-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-pill {
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-button {
|
||||
min-width: 96px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-field code,
|
||||
.dashboard-page .copy-field input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-reveal-button {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.dashboard-status-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--rp-soft-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-status-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
border-bottom: 1px solid var(--rp-soft-line);
|
||||
}
|
||||
|
||||
.dashboard-status-list div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-status-list dt,
|
||||
.dashboard-status-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-status-list dt {
|
||||
color: var(--rp-muted);
|
||||
background: var(--rp-surface-alt);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-status-list dd {
|
||||
font: 500 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
padding: 24px 0 0;
|
||||
}
|
||||
|
||||
.dashboard-section-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.75fr) minmax(0, 1.25fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-form-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-table-shell {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--rp-line);
|
||||
border-radius: 12px;
|
||||
background: var(--rp-surface);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.dashboard-history-table {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.dashboard-history-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--rp-surface-alt);
|
||||
}
|
||||
|
||||
.dashboard-history-table th,
|
||||
.dashboard-history-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--rp-line);
|
||||
border-radius: 999px;
|
||||
background: var(--rp-field);
|
||||
color: var(--rp-charcoal);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge-ok {
|
||||
color: var(--rp-success);
|
||||
}
|
||||
|
||||
.status-badge-error {
|
||||
color: var(--rp-danger);
|
||||
}
|
||||
|
||||
.dashboard-danger-section {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.dashboard-danger-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--rp-line);
|
||||
border-radius: 12px;
|
||||
background: var(--rp-surface);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.dashboard-shell {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.dashboard-header,
|
||||
.dashboard-card-header,
|
||||
.dashboard-danger-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.dashboard-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dashboard-card,
|
||||
.dashboard-danger-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-button,
|
||||
.dashboard-actions .btn,
|
||||
.dashboard-card-header .btn,
|
||||
.dashboard-danger-card .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-page .copy-field {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-status-list div {
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child th,
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
|
||||
@@ -4,12 +4,11 @@ from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
_code_window,
|
||||
_copy_menu,
|
||||
_copy_button,
|
||||
_copy_row,
|
||||
_event_label,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
@@ -46,99 +45,98 @@ def _render_management_page(
|
||||
return _page(
|
||||
"KarlDNS management",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
<main id="main" class="dashboard-page">
|
||||
{_top_nav()}
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<section class="dashboard-shell">
|
||||
<div class="container">
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
|
||||
<p class="lead">Private management link. Keep it safe.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-6">
|
||||
<p class="eyebrow">Router settings</p>
|
||||
<h2>Router update settings</h2>
|
||||
<p class="section-copy">Use this Update-URL in your router or script. Optional: point another DNS name at this hostname with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<div class="copy-actions mt-3">
|
||||
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
</div>
|
||||
</div>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div class="dashboard-grid">
|
||||
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
|
||||
<div class="dashboard-card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Actions</p>
|
||||
<h3>Replace router update URL</h3>
|
||||
<p class="section-copy">Create a new update URL. Update your router after changing it.</p>
|
||||
<p class="eyebrow">Router settings</p>
|
||||
<h2 id="router-settings-title">Update endpoint</h2>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<div class="dashboard-copy-stack">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
{_copy_button("Router fields", fritzbox_fields)}
|
||||
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
|
||||
<p class="eyebrow">Current address</p>
|
||||
<h2 id="status-title">Status</h2>
|
||||
<dl class="dashboard-status-list">
|
||||
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
|
||||
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{updated}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
|
||||
<div class="dashboard-card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Manual updates</p>
|
||||
<h2 id="manual-updates-title">curl examples</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
|
||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{dashboard_password_panel}
|
||||
<section class="section">
|
||||
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||
<div>
|
||||
<p class="eyebrow">Current address</p>
|
||||
<h2>Status</h2>
|
||||
</div>
|
||||
<div class="table-responsive status-table-shell">
|
||||
<table class="table status-table mb-0">
|
||||
<tbody>
|
||||
<tr><th>IPv4</th><td>{ipv4}</td></tr>
|
||||
<tr><th>IPv6</th><td>{ipv6}</td></tr>
|
||||
<tr><th>Updated</th><td>{updated}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<section class="dashboard-section">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||
<div class="dashboard-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">History</p>
|
||||
<h2>Router updates</h2>
|
||||
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<div class="table-responsive dashboard-table-shell">
|
||||
<table class="table table-hover dashboard-history-table mb-0">
|
||||
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
|
||||
<tbody>{history_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section danger-section">
|
||||
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||
<section class="dashboard-section dashboard-danger-section">
|
||||
<div class="container">
|
||||
<div class="dashboard-danger-card">
|
||||
<div>
|
||||
<p class="eyebrow">Delete</p>
|
||||
<h2>Delete hostname</h2>
|
||||
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
|
||||
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
|
||||
</div>
|
||||
<form method="post" action="/m/{attr(management_slug)}/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
@@ -241,7 +239,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
remove_form = ""
|
||||
if password_enabled:
|
||||
remove_form = f"""
|
||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
|
||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
|
||||
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||
@@ -258,16 +256,16 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
||||
"""
|
||||
return trusted(
|
||||
f"""
|
||||
<section class="section">
|
||||
<section class="dashboard-section">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="dashboard-settings-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard password</p>
|
||||
<h2>Password protection</h2>
|
||||
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
|
||||
</div>
|
||||
<div class="col-lg-7 d-grid gap-3">
|
||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
|
||||
<div class="d-grid gap-3">
|
||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
{current_password_field}
|
||||
<div>
|
||||
@@ -337,9 +335,19 @@ def _render_management_unlock_page(
|
||||
|
||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
raw_status = str(event.get("status") or "")
|
||||
status = escape(_event_label(raw_status))
|
||||
badge_class = attr(_event_badge_class(raw_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>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
|
||||
return f'<tr><td>{created}</td><td><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
|
||||
|
||||
|
||||
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"
|
||||
Reference in new issue
Block a user