feat: implement pngx-controller with Gitea CI/CD deployment
All checks were successful
Deploy / deploy (push) Successful in 30s
All checks were successful
Deploy / deploy (push) Successful in 30s
- Full FastAPI sync engine: master→replica document sync via paperless REST API - Web UI: dashboard, replicas, logs, settings (Jinja2 + HTMX + Pico CSS) - APScheduler background sync, SSE live log stream, Prometheus metrics - Fernet encryption for API tokens at rest - pngx.env credential file: written on save, pre-fills forms on load - Dockerfile with layer-cached uv build, Python healthcheck - docker-compose with host networking for Tailscale access - Gitea Actions workflow: version bump, secret injection, docker compose deploy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
152
app/templates/replica_detail.html
Normal file
152
app/templates/replica_detail.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ replica.name }} — pngx-controller{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li><a href="/replicas">Replicas</a></li>
|
||||
<li>{{ replica.name }}</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem;">
|
||||
<h2 style="margin:0;">{{ replica.name }}</h2>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button class="secondary outline"
|
||||
hx-post="/api/replicas/{{ replica.id }}/test"
|
||||
hx-target="#conn-result" hx-swap="innerHTML">
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
hx-post="/api/sync?replica_id={{ replica.id }}"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
⟳ Sync Now
|
||||
</button>
|
||||
{% if replica.suspended_at %}
|
||||
<button class="contrast"
|
||||
hx-post="/api/replicas/{{ replica.id }}/unsuspend"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Re-enable
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span id="conn-result" style="margin-left:0.5rem;"></span>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:1rem; margin: 1.5rem 0;">
|
||||
<article style="padding:1rem;">
|
||||
<header style="margin-bottom:0.25rem;"><small class="muted">URL</small></header>
|
||||
<code>{{ replica.url }}</code>
|
||||
</article>
|
||||
<article style="padding:1rem;">
|
||||
<header style="margin-bottom:0.25rem;"><small class="muted">Status</small></header>
|
||||
{% if replica.suspended_at %}
|
||||
<span class="badge badge-suspended">suspended</span>
|
||||
<br><small class="muted">{{ replica.consecutive_failures }} consecutive failures</small>
|
||||
{% elif replica.last_sync_ts %}
|
||||
<span class="badge badge-synced">synced</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">pending</span>
|
||||
{% endif %}
|
||||
</article>
|
||||
<article style="padding:1rem;">
|
||||
<header style="margin-bottom:0.25rem;"><small class="muted">Last sync</small></header>
|
||||
{% if replica.last_sync_ts %}
|
||||
{{ replica.last_sync_ts.strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
||||
<br><small class="muted">{{ lag }}</small>
|
||||
{% else %}
|
||||
<small class="muted">never</small>
|
||||
{% endif %}
|
||||
</article>
|
||||
<article style="padding:1rem;">
|
||||
<header style="margin-bottom:0.25rem;"><small class="muted">Interval</small></header>
|
||||
{% if replica.sync_interval_seconds %}{{ replica.sync_interval_seconds }}s{% else %}global{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h3>Sync Run History (last 20)</h3>
|
||||
{% if recent_runs %}
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Started</th><th>Duration</th><th>Synced</th><th>Failed</th><th>Triggered by</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.id }}</td>
|
||||
<td>{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S') if run.started_at else '—' }}</td>
|
||||
<td>
|
||||
{% if run.started_at and run.finished_at %}
|
||||
{% set dur = (run.finished_at - run.started_at).total_seconds()|int %}
|
||||
{% if dur < 60 %}{{ dur }}s
|
||||
{% else %}{{ dur // 60 }}m {{ dur % 60 }}s{% endif %}
|
||||
{% elif run.started_at %}
|
||||
running…
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ run.docs_synced }}</td>
|
||||
<td>
|
||||
{% if run.docs_failed %}
|
||||
<a href="/logs?replica_id={{ replica.id }}" class="badge badge-error">{{ run.docs_failed }}</a>
|
||||
{% else %}0{% endif %}
|
||||
</td>
|
||||
<td><code>{{ run.triggered_by }}</code></td>
|
||||
<td>{% if run.timed_out %}<span class="badge badge-warning">timed out</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p><small class="muted">No sync runs yet.</small></p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Sync Map (last 50)</h3>
|
||||
{% if sync_map_page %}
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Master ID</th><th>Replica ID</th><th>Status</th><th>Last synced</th><th>Retries</th><th>Error</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in sync_map_page %}
|
||||
<tr>
|
||||
<td>{{ entry.master_doc_id }}</td>
|
||||
<td>{{ entry.replica_doc_id or '—' }}</td>
|
||||
<td><span class="badge badge-{{ entry.status }}">{{ entry.status }}</span></td>
|
||||
<td>{{ entry.last_synced.strftime('%Y-%m-%d %H:%M') if entry.last_synced else '—' }}</td>
|
||||
<td>{{ entry.retry_count }}</td>
|
||||
<td><small>{{ entry.error_msg or '' }}</small></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p><small class="muted">No sync map entries yet.</small></p>
|
||||
{% endif %}
|
||||
|
||||
<details style="margin-top:2rem;">
|
||||
<summary>Danger Zone</summary>
|
||||
<div style="padding:1rem; border:1px solid var(--pico-del-color); border-radius:0.5rem; margin-top:0.5rem;">
|
||||
<p>Full resync wipes the sync map for this replica and re-syncs everything from scratch.</p>
|
||||
<button class="contrast"
|
||||
hx-post="/api/replicas/{{ replica.id }}/resync"
|
||||
hx-confirm="Wipe sync map and trigger full resync for {{ replica.name }}?"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Full Resync
|
||||
</button>
|
||||
<button class="secondary"
|
||||
hx-post="/api/replicas/{{ replica.id }}/reconcile"
|
||||
hx-confirm="Run reconcile for {{ replica.name }}? This matches existing documents without re-uploading."
|
||||
hx-target="#reconcile-result" hx-swap="innerHTML">
|
||||
Reconcile
|
||||
</button>
|
||||
<span id="reconcile-result" style="margin-left:0.5rem;"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user