feat: full UI overhaul — dark theme with custom design system
All checks were successful
Deploy / deploy (push) Successful in 39s

Replace Pico CSS with a custom dark design system:
- Deep navy background (#090b12) with violet-indigo accent (#7b6cf5)
- Outfit font for UI, JetBrains Mono for code/URLs
- Custom nav with SVG icons and active-state highlighting
- Pill badges with colored status dots
- Uppercase letter-spaced table headers
- Stat cards on replica detail page
- h3 section headings with extending rule line
- Refined dialog/modal styling with blur backdrop
- Terminal-style SSE log stream
- Progress bar with accent gradient animation
- Page entrance fade-in animation
- All Pico CSS variable references replaced with custom tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 22:52:35 +01:00
parent 849c047f2c
commit 7b1a85bc84
7 changed files with 893 additions and 359 deletions

View File

@@ -2,37 +2,41 @@
{% block title %}Dashboard — pngx-controller{% endblock %}
{% block content %}
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem; margin-bottom:1rem;">
<h2 style="margin:0;">Dashboard</h2>
<div style="display:flex; gap:0.5rem; align-items:center;">
<div class="page-hd">
<div>
<h2>Dashboard</h2>
</div>
<div class="page-actions">
<button id="sync-btn"
hx-post="/api/sync"
hx-swap="none"
hx-on::after-request="startPolling()"
class="{% if progress.running %}secondary{% endif %}">
{% if progress.running %}Syncing…{% else %}⟳ Sync Now{% endif %}
{% if progress.running %}
Syncing&hellip;
{% else %}
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M11 6.5A4.5 4.5 0 112.5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 1.5V4H5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
Sync Now
{% endif %}
</button>
</div>
</div>
{% if flash %}
<div role="alert" style="padding:0.6rem 1rem; margin-bottom:1rem; background:#eff6ff; color:#1d4ed8; border-radius:0.4rem; border-left:3px solid #1d4ed8; font-size:0.95em;">
{{ flash }}
</div>
<div role="alert">{{ flash }}</div>
{% endif %}
<!-- Progress bar -->
<div id="progress-bar" class="{% if progress.running %}active{% endif %}"
hx-get="/api/sync/running"
hx-trigger="every 2s"
hx-target="#progress-inner"
hx-swap="innerHTML">
<article style="padding:0.75rem;">
<article>
<div id="progress-inner">
{% if progress.running %}
<strong>{{ progress.phase }}</strong>
<strong style="font-size:.85rem;">{{ progress.phase }}</strong>
{% if progress.docs_total > 0 %}
{{ progress.docs_done }} / {{ progress.docs_total }} documents
<span style="color:var(--text-3); font-size:.8rem; margin-left:.5rem;">{{ progress.docs_done }} / {{ progress.docs_total }} documents</span>
<progress value="{{ progress.docs_done }}" max="{{ progress.docs_total }}"></progress>
{% else %}
<progress></progress>
@@ -42,33 +46,31 @@
</article>
</div>
<!-- Last sync summary -->
{% if last_run %}
<details>
<details style="margin-bottom:1.25rem;">
<summary>
Last sync run:
Last sync run &mdash;
{% if last_run.finished_at %}
finished {{ last_run.finished_at.strftime('%Y-%m-%d %H:%M:%S') }} UTC
&mdash; {{ last_run.docs_synced }} synced, {{ last_run.docs_failed }} failed
{% if last_run.timed_out %}<span class="badge badge-error">timed out</span>{% endif %}
{{ last_run.finished_at.strftime('%Y-%m-%d %H:%M') }} UTC
&nbsp;&middot;&nbsp; {{ last_run.docs_synced }} synced, {{ last_run.docs_failed }} failed
{% if last_run.timed_out %}&nbsp;<span class="badge badge-warning">timed out</span>{% endif %}
{% else %}
running
running&hellip;
{% endif %}
</summary>
<p>Triggered by: <code>{{ last_run.triggered_by }}</code> &mdash; Run #{{ last_run.id }}</p>
<p style="font-size:.83rem; color:var(--text-2); padding-top:.5rem;">Triggered by: <code>{{ last_run.triggered_by }}</code> &mdash; Run #{{ last_run.id }}</p>
</details>
{% endif %}
<!-- Live doc counts -->
<div id="doc-counts"
hx-get="/ui/doc-counts"
hx-trigger="load, every 120s"
hx-swap="innerHTML">
<small class="muted">Loading document counts</small>
<small class="muted">Loading document counts&hellip;</small>
</div>
<!-- Replica table -->
<h3>Replicas</h3>
{% if replica_rows %}
<div class="overflow-auto">
<table>
@@ -85,29 +87,31 @@
<tbody>
{% for row in replica_rows %}
<tr>
<td><a href="/replicas/{{ row.replica.id }}">{{ row.replica.name }}</a></td>
<td><small>{{ row.replica.url }}</small></td>
<td><a href="/replicas/{{ row.replica.id }}" style="font-weight:500;">{{ row.replica.name }}</a></td>
<td><code>{{ row.replica.url }}</code></td>
<td>
<span class="badge badge-{{ row.status }}">{{ row.status }}</span>
{% if row.replica.suspended_at %}
<br><small class="muted">{{ row.replica.consecutive_failures }} failures</small>
{% endif %}
</td>
<td>{{ row.lag }}</td>
<td style="color:var(--text-2); font-size:.82rem;">{{ row.lag }}</td>
<td>
{% if row.last_run %}
<small>
{{ row.last_run.docs_synced }}
{% if row.last_run.docs_failed %} · ✗ <a href="/logs?replica_id={{ row.replica.id }}">{{ row.last_run.docs_failed }}</a>{% endif %}
<span style="color:var(--ok);"></span> {{ row.last_run.docs_synced }}
{% if row.last_run.docs_failed %}
&nbsp;<span style="color:var(--err);"></span> <a href="/logs?replica_id={{ row.replica.id }}" style="color:var(--err);">{{ row.last_run.docs_failed }}</a>
{% endif %}
</small>
{% else %}
<small class="muted">never</small>
{% endif %}
</td>
<td class="actions">
<a href="/replicas/{{ row.replica.id }}" role="button" class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;">Details</a>
<a href="/replicas/{{ row.replica.id }}" role="button" class="secondary outline" style="padding:.25em .65em; font-size:.78rem;">Details</a>
{% if row.replica.suspended_at %}
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
<button class="contrast outline" style="padding:.25em .65em; font-size:.78rem;"
hx-post="/api/replicas/{{ row.replica.id }}/unsuspend"
hx-on::after-request="window.location.reload()">
Re-enable
@@ -120,27 +124,25 @@
</table>
</div>
{% else %}
<p>No replicas configured. <a href="/replicas">Add one </a></p>
<p>No replicas configured. <a href="/replicas">Add one &rarr;</a></p>
{% endif %}
<script>
function startPolling() {
document.getElementById('progress-bar').classList.add('active');
document.getElementById('sync-btn').textContent = 'Syncing…';
document.getElementById('sync-btn').setAttribute('disabled', true);
// polling is driven by hx-trigger="every 2s" on the progress bar
// stop when running=false
const btn = document.getElementById('sync-btn');
btn.textContent = 'Syncing\u2026';
btn.setAttribute('disabled', true);
}
// Watch progress updates to hide bar when done
document.body.addEventListener('htmx:afterSettle', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'progress-inner') {
// Re-read progress via a quick fetch
fetch('/api/sync/running').then(r => r.json()).then(data => {
if (!data.running) {
document.getElementById('progress-bar').classList.remove('active');
document.getElementById('sync-btn').textContent = '⟳ Sync Now';
document.getElementById('sync-btn').removeAttribute('disabled');
const btn = document.getElementById('sync-btn');
btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M11 6.5A4.5 4.5 0 112.5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 1.5V4H5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> Sync Now';
btn.removeAttribute('disabled');
}
});
}