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:
55
app/templates/base.html
Normal file
55
app/templates/base.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}pngx-controller{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||
<style>
|
||||
nav { padding: 0.5rem 1rem; }
|
||||
.badge { display: inline-block; padding: 0.15em 0.6em; border-radius: 1em; font-size: 0.8em; font-weight: 600; }
|
||||
.badge-synced { background: #d1fae5; color: #065f46; }
|
||||
.badge-syncing { background: #dbeafe; color: #1e40af; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-suspended { background: #fef3c7; color: #92400e; }
|
||||
.badge-pending { background: #f3f4f6; color: #374151; }
|
||||
.badge-ok { background: #d1fae5; color: #065f46; }
|
||||
.badge-info { background: #eff6ff; color: #1d4ed8; }
|
||||
.badge-warning { background: #fffbeb; color: #b45309; }
|
||||
small.muted { color: var(--pico-muted-color); }
|
||||
#progress-bar { display: none; }
|
||||
#progress-bar.active { display: block; }
|
||||
.log-entry-error td { background: #fff5f5; }
|
||||
.log-entry-warning td { background: #fffbeb; }
|
||||
pre { white-space: pre-wrap; font-size: 0.8em; }
|
||||
table { font-size: 0.9em; }
|
||||
.actions { white-space: nowrap; }
|
||||
details summary { cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="container-fluid">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><strong><a href="/" style="text-decoration:none;">📄 pngx-controller</a></strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/replicas">Replicas</a></li>
|
||||
<li><a href="/logs">Logs</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="container-fluid" style="text-align:center; padding:1rem; color:var(--pico-muted-color); font-size:0.8em;">
|
||||
pngx-controller — <a href="/healthz">health</a> — <a href="/metrics">metrics</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
136
app/templates/dashboard.html
Normal file
136
app/templates/dashboard.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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;">
|
||||
<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 %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<div id="progress-inner">
|
||||
{% if progress.running %}
|
||||
<strong>{{ progress.phase }}</strong>
|
||||
{% if progress.docs_total > 0 %}
|
||||
— {{ progress.docs_done }} / {{ progress.docs_total }} documents
|
||||
<progress value="{{ progress.docs_done }}" max="{{ progress.docs_total }}"></progress>
|
||||
{% else %}
|
||||
<progress></progress>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Last sync summary -->
|
||||
{% if last_run %}
|
||||
<details>
|
||||
<summary>
|
||||
Last sync run:
|
||||
{% if last_run.finished_at %}
|
||||
finished {{ last_run.finished_at.strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
||||
— {{ last_run.docs_synced }} synced, {{ last_run.docs_failed }} failed
|
||||
{% if last_run.timed_out %}<span class="badge badge-error">timed out</span>{% endif %}
|
||||
{% else %}
|
||||
running…
|
||||
{% endif %}
|
||||
</summary>
|
||||
<p>Triggered by: <code>{{ last_run.triggered_by }}</code> — Run #{{ last_run.id }}</p>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Replica table -->
|
||||
<h3>Replicas</h3>
|
||||
{% if replica_rows %}
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>Lag</th>
|
||||
<th>Last run</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
</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>
|
||||
{% if row.replica.suspended_at %}
|
||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
||||
hx-post="/api/replicas/{{ row.replica.id }}/unsuspend"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Re-enable
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No replicas configured. <a href="/replicas">Add one →</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
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
115
app/templates/logs.html
Normal file
115
app/templates/logs.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs — pngx-controller{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Logs</h2>
|
||||
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1rem;">
|
||||
<label style="flex:1; min-width:150px;">
|
||||
Replica
|
||||
<select id="filter-replica" onchange="applyFilters()">
|
||||
<option value="">All</option>
|
||||
{% for r in replicas %}
|
||||
<option value="{{ r.id }}">{{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label style="flex:1; min-width:120px;">
|
||||
Level
|
||||
<select id="filter-level" onchange="applyFilters()">
|
||||
<option value="">All</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="flex:2; min-width:200px;">
|
||||
Search
|
||||
<input type="text" id="filter-q" placeholder="Full-text search…" oninput="debounceFilter()">
|
||||
</label>
|
||||
<button class="secondary outline" onclick="applyFilters()">Filter</button>
|
||||
<button class="contrast outline"
|
||||
hx-delete="/api/logs?older_than_days=90"
|
||||
hx-confirm="Delete logs older than 90 days?"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Clear old logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Live SSE tail -->
|
||||
<details open id="live-section">
|
||||
<summary>Live log stream</summary>
|
||||
<div id="sse-log"
|
||||
hx-ext="sse"
|
||||
sse-connect="/api/logs/stream"
|
||||
style="font-family:monospace; font-size:0.8em; max-height:300px; overflow-y:auto; border:1px solid var(--pico-muted-border-color); padding:0.5rem; border-radius:0.5rem; background:var(--pico-code-background-color);">
|
||||
<div sse-swap="message" hx-swap="afterbegin">
|
||||
<span class="muted">Connecting to log stream…</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Static log table -->
|
||||
<div id="log-table-area">
|
||||
{% include "partials/log_table.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let debounceTimer;
|
||||
function debounceFilter() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applyFilters, 400);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const replica = document.getElementById('filter-replica').value;
|
||||
const level = document.getElementById('filter-level').value;
|
||||
const q = document.getElementById('filter-q').value;
|
||||
const params = new URLSearchParams();
|
||||
if (replica) params.set('replica_id', replica);
|
||||
if (level) params.set('level', level);
|
||||
if (q) params.set('q', q);
|
||||
|
||||
htmx.ajax('GET', '/api/logs?' + params.toString(), {
|
||||
target: '#log-table-area',
|
||||
swap: 'innerHTML',
|
||||
handler: function(elt, info) {
|
||||
// render JSON as table
|
||||
const data = JSON.parse(info.xhr.responseText);
|
||||
const rows = data.map(l => `
|
||||
<tr class="log-entry-${l.level || 'info'}">
|
||||
<td><small>${l.created_at || ''}</small></td>
|
||||
<td><span class="badge badge-${l.level}">${l.level || ''}</span></td>
|
||||
<td>${l.replica_id || ''}</td>
|
||||
<td>${l.doc_id || ''}</td>
|
||||
<td>${l.message || ''}</td>
|
||||
</tr>`).join('');
|
||||
elt.innerHTML = `<table><thead><tr><th>Time</th><th>Level</th><th>Replica</th><th>Doc</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pretty-print SSE messages
|
||||
document.body.addEventListener('htmx:sseMessage', function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.detail.data);
|
||||
const el = document.createElement('div');
|
||||
const color = data.level === 'error' ? '#f87171' : data.level === 'warning' ? '#fbbf24' : '#6ee7b7';
|
||||
el.style.color = color;
|
||||
el.innerHTML = `[${data.ts}] ${data.level?.toUpperCase()} ${data.replica_id ? '[r:'+data.replica_id+']' : ''} ${data.doc_id ? '[d:'+data.doc_id+']' : ''} ${data.message}`;
|
||||
const container = document.querySelector('#sse-log div[sse-swap]');
|
||||
if (container) {
|
||||
const first = container.querySelector('span.muted');
|
||||
if (first) first.remove();
|
||||
container.insertBefore(el, container.firstChild);
|
||||
// Keep max 200 entries
|
||||
while (container.children.length > 200) container.removeChild(container.lastChild);
|
||||
}
|
||||
} catch(err) {}
|
||||
return false; // prevent default HTMX swap
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
28
app/templates/partials/log_table.html
Normal file
28
app/templates/partials/log_table.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% if logs %}
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Level</th>
|
||||
<th>Replica</th>
|
||||
<th>Doc ID</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr class="log-entry-{{ log.level or 'info' }}">
|
||||
<td><small>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else '' }}</small></td>
|
||||
<td><span class="badge badge-{{ log.level or 'info' }}">{{ log.level or 'info' }}</span></td>
|
||||
<td>{{ log.replica_id or '' }}</td>
|
||||
<td>{{ log.doc_id or '' }}</td>
|
||||
<td>{{ log.message or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p><small class="muted">No log entries.</small></p>
|
||||
{% endif %}
|
||||
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 %}
|
||||
193
app/templates/replicas.html
Normal file
193
app/templates/replicas.html
Normal file
@@ -0,0 +1,193 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Replicas — pngx-controller{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Replicas</h2>
|
||||
|
||||
{% if replicas %}
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Enabled</th>
|
||||
<th>Interval</th>
|
||||
<th>Last Sync</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in replicas %}
|
||||
<tr>
|
||||
<td><a href="/replicas/{{ r.id }}">{{ r.name }}</a></td>
|
||||
<td><code>{{ r.url }}</code></td>
|
||||
<td>{% if r.enabled %}✅{% else %}⛔{% endif %}</td>
|
||||
<td>
|
||||
{% if r.sync_interval_seconds %}{{ r.sync_interval_seconds }}s{% else %}<small class="muted">global</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.last_sync_ts %}{{ r.last_sync_ts.strftime('%Y-%m-%d %H:%M') }}{% else %}<small class="muted">never</small>{% endif %}
|
||||
{% if r.suspended_at %}
|
||||
<br><span class="badge badge-suspended">suspended · {{ r.consecutive_failures }} failures</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a href="/replicas/{{ r.id }}" role="button" class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;">Detail</a>
|
||||
<button class="outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
||||
hx-post="/api/replicas/{{ r.id }}/test"
|
||||
hx-target="#test-result-{{ r.id }}"
|
||||
hx-swap="innerHTML">
|
||||
Test
|
||||
</button>
|
||||
{% if r.suspended_at %}
|
||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
||||
hx-post="/api/replicas/{{ r.id }}/unsuspend"
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Re-enable
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
||||
onclick="openEditModal({{ r.id }}, '{{ r.name }}', '{{ r.url }}', {{ r.enabled|lower }}, {{ r.sync_interval_seconds or 'null' }})">
|
||||
Edit
|
||||
</button>
|
||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
||||
hx-delete="/api/replicas/{{ r.id }}"
|
||||
hx-confirm="Delete replica {{ r.name }}? This also removes its sync map."
|
||||
hx-on::after-request="window.location.reload()">
|
||||
Delete
|
||||
</button>
|
||||
<span id="test-result-{{ r.id }}" style="font-size:0.8em;"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No replicas yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>Add Replica</h3>
|
||||
|
||||
{% if env_replicas %}
|
||||
<details open style="margin-bottom:1rem;">
|
||||
<summary><strong>From pngx.env</strong> — {{ env_replicas|length }} not yet added</summary>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem;">
|
||||
{% for er in env_replicas %}
|
||||
{% set er_name = er.safe | lower | replace('_', '-') %}
|
||||
<button type="button" class="outline" style="font-size:0.85em;"
|
||||
onclick="prefillAdd('{{ er_name }}', '{{ er.url }}', '{{ er.token }}')">
|
||||
+ {{ er.safe }} <small style="opacity:0.6;">{{ er.url }}</small>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div id="add-form-area">
|
||||
<form id="add-replica-form" onsubmit="submitAddReplica(event)">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||
<label>Name <input type="text" name="name" required placeholder="backup"></label>
|
||||
<label>URL <input type="url" name="url" required placeholder="http://100.y.y.y:8000"></label>
|
||||
<label>API Token <input type="password" name="api_token" required></label>
|
||||
<label>Sync Interval (seconds, blank = global)
|
||||
<input type="number" name="sync_interval_seconds" placeholder="900" min="60">
|
||||
</label>
|
||||
<label style="align-self:end;">
|
||||
<input type="checkbox" name="enabled" checked> Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div id="add-test-result" style="margin:0.5rem 0;"></div>
|
||||
<button type="submit">Add Replica</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<dialog id="edit-modal">
|
||||
<article>
|
||||
<header><h3>Edit Replica</h3></header>
|
||||
<form id="edit-replica-form" onsubmit="submitEditReplica(event)">
|
||||
<input type="hidden" name="id">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>URL <input type="url" name="url" required></label>
|
||||
<label>API Token (leave blank to keep current) <input type="password" name="api_token" placeholder="unchanged"></label>
|
||||
<label>Sync Interval (seconds) <input type="number" name="sync_interval_seconds" min="60"></label>
|
||||
<label><input type="checkbox" name="enabled"> Enabled</label>
|
||||
<div id="edit-test-result" style="margin:0.5rem 0;"></div>
|
||||
<footer>
|
||||
<button type="button" class="secondary" onclick="document.getElementById('edit-modal').close()">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function prefillAdd(name, url, token) {
|
||||
const form = document.getElementById('add-replica-form');
|
||||
form.querySelector('[name=name]').value = name;
|
||||
form.querySelector('[name=url]').value = url;
|
||||
if (token) form.querySelector('[name=api_token]').value = token;
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function submitAddReplica(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
name: data.name, url: data.url, api_token: data.api_token,
|
||||
enabled: form.enabled.checked,
|
||||
sync_interval_seconds: data.sync_interval_seconds ? parseInt(data.sync_interval_seconds) : null
|
||||
};
|
||||
const resultEl = document.getElementById('add-test-result');
|
||||
resultEl.textContent = 'Saving…';
|
||||
const r = await fetch('/api/replicas', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
const json = await r.json();
|
||||
if (r.ok) {
|
||||
resultEl.innerHTML = '<span class="badge badge-ok">✓ Saved — doc count: ' + (json.doc_count || 0) + '</span>';
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} else {
|
||||
resultEl.innerHTML = '<span class="badge badge-error">✗ ' + (json.detail || 'Error') + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(id, name, url, enabled, interval) {
|
||||
const form = document.getElementById('edit-replica-form');
|
||||
form.id_field = id;
|
||||
form.name.value = name;
|
||||
form.url.value = url;
|
||||
form.enabled.checked = enabled;
|
||||
form.sync_interval_seconds.value = interval || '';
|
||||
form.api_token.value = '';
|
||||
document.getElementById('edit-modal').showModal();
|
||||
}
|
||||
|
||||
async function submitEditReplica(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const id = form.id_field;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
name: data.name, url: data.url,
|
||||
enabled: form.enabled.checked,
|
||||
sync_interval_seconds: data.sync_interval_seconds ? parseInt(data.sync_interval_seconds) : undefined
|
||||
};
|
||||
if (data.api_token) body.api_token = data.api_token;
|
||||
const resultEl = document.getElementById('edit-test-result');
|
||||
resultEl.textContent = 'Saving…';
|
||||
const r = await fetch('/api/replicas/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
const json = await r.json();
|
||||
if (r.ok) {
|
||||
resultEl.innerHTML = '<span class="badge badge-ok">✓ Saved</span>';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
resultEl.innerHTML = '<span class="badge badge-error">✗ ' + (json.detail || 'Error') + '</span>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
169
app/templates/settings.html
Normal file
169
app/templates/settings.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — pngx-controller{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Settings</h2>
|
||||
|
||||
<form onsubmit="saveSettings(event)">
|
||||
<h3>Master Instance</h3>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||
<label>
|
||||
Master URL
|
||||
<input type="url" name="master_url" value="{{ settings.get('master_url', '') }}" placeholder="http://100.x.x.x:8000">
|
||||
</label>
|
||||
<label>
|
||||
Master API Token
|
||||
<input type="password" name="master_token" value="{{ env_master_token }}" placeholder="leave blank to keep current">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>Sync Engine</h3>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:1rem;">
|
||||
<label>
|
||||
Sync Interval (seconds)
|
||||
<input type="number" name="sync_interval_seconds" value="{{ settings.get('sync_interval_seconds', '900') }}" min="60">
|
||||
</label>
|
||||
<label>
|
||||
Cycle Timeout (seconds)
|
||||
<input type="number" name="sync_cycle_timeout_seconds" value="{{ settings.get('sync_cycle_timeout_seconds', '1800') }}" min="60">
|
||||
</label>
|
||||
<label>
|
||||
Task Poll Timeout (seconds)
|
||||
<input type="number" name="task_poll_timeout_seconds" value="{{ settings.get('task_poll_timeout_seconds', '600') }}" min="30">
|
||||
</label>
|
||||
<label>
|
||||
Max Concurrent Requests
|
||||
<input type="number" name="max_concurrent_requests" value="{{ settings.get('max_concurrent_requests', '4') }}" min="1" max="20">
|
||||
</label>
|
||||
<label>
|
||||
Suspend Threshold (consecutive failures)
|
||||
<input type="number" name="replica_suspend_threshold" value="{{ settings.get('replica_suspend_threshold', '5') }}" min="1">
|
||||
</label>
|
||||
<label>
|
||||
Log Retention (days)
|
||||
<input type="number" name="log_retention_days" value="{{ settings.get('log_retention_days', '90') }}" min="1">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>Notifications</h3>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:1rem;">
|
||||
<label>
|
||||
Alert Target
|
||||
<select name="alert_target_type">
|
||||
<option value="" {% if not settings.get('alert_target_type') %}selected{% endif %}>Disabled</option>
|
||||
<option value="gotify" {% if settings.get('alert_target_type') == 'gotify' %}selected{% endif %}>Gotify</option>
|
||||
<option value="webhook" {% if settings.get('alert_target_type') == 'webhook' %}selected{% endif %}>Webhook</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Alert URL
|
||||
<input type="url" name="alert_target_url" value="{{ settings.get('alert_target_url', '') }}" placeholder="https://…">
|
||||
</label>
|
||||
<label>
|
||||
Alert Token / Auth Header
|
||||
<input type="password" name="alert_target_token" value="{{ env_alert_token }}" placeholder="leave blank to keep current">
|
||||
</label>
|
||||
<label>
|
||||
Alert Error Threshold (docs failed)
|
||||
<input type="number" name="alert_error_threshold" value="{{ settings.get('alert_error_threshold', '5') }}" min="1">
|
||||
</label>
|
||||
<label>
|
||||
Alert Cooldown (seconds)
|
||||
<input type="number" name="alert_cooldown_seconds" value="{{ settings.get('alert_cooldown_seconds', '3600') }}" min="60">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem; display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<button type="submit" id="save-btn">Save Settings</button>
|
||||
<button type="button" id="test-btn" class="secondary outline" onclick="testConnection()">
|
||||
Test Connection
|
||||
</button>
|
||||
<span id="settings-result"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function formBody() {
|
||||
const form = document.querySelector('form');
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const body = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v === '') continue;
|
||||
const isNumericKey = ['sync_interval_seconds','sync_cycle_timeout_seconds',
|
||||
'task_poll_timeout_seconds','max_concurrent_requests','replica_suspend_threshold',
|
||||
'log_retention_days','alert_error_threshold','alert_cooldown_seconds'].includes(k);
|
||||
body[k] = isNumericKey ? Number(v) : v;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function setResult(html) {
|
||||
document.getElementById('settings-result').innerHTML = html;
|
||||
}
|
||||
|
||||
function setLoading(btn, label) {
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.disabled = true;
|
||||
btn.dataset.orig = btn.textContent;
|
||||
btn.textContent = label;
|
||||
}
|
||||
|
||||
function clearLoading(btn) {
|
||||
btn.removeAttribute('aria-busy');
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.orig || btn.textContent;
|
||||
}
|
||||
|
||||
async function saveSettings(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('save-btn');
|
||||
setLoading(btn, 'Saving…');
|
||||
setResult('');
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(formBody()),
|
||||
});
|
||||
const json = await r.json();
|
||||
if (r.ok) {
|
||||
setResult('<span class="badge badge-ok">✓ Settings saved</span>');
|
||||
} else {
|
||||
const detail = typeof json.detail === 'string' ? json.detail : JSON.stringify(json.detail);
|
||||
setResult('<span class="badge badge-error">✗ ' + detail + '</span>');
|
||||
}
|
||||
} catch (err) {
|
||||
setResult('<span class="badge badge-error">✗ Network error: ' + err.message + '</span>');
|
||||
} finally {
|
||||
clearLoading(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const btn = document.getElementById('test-btn');
|
||||
setLoading(btn, 'Testing…');
|
||||
setResult('');
|
||||
try {
|
||||
const form = document.querySelector('form');
|
||||
const url = form.querySelector('[name=master_url]').value.trim();
|
||||
const token = form.querySelector('[name=master_token]').value.trim();
|
||||
const r = await fetch('/api/settings/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({url, token}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
setResult('<span class="badge badge-ok">✓ Connected — ' + data.doc_count + ' docs · ' + data.latency_ms + 'ms</span>');
|
||||
} else {
|
||||
setResult('<span class="badge badge-error">✗ ' + (data.error || 'Connection failed') + '</span>');
|
||||
}
|
||||
} catch (err) {
|
||||
setResult('<span class="badge badge-error">✗ ' + err.message + '</span>');
|
||||
} finally {
|
||||
clearLoading(btn);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user