feat: implement pngx-controller with Gitea CI/CD deployment
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:
2026-03-22 17:59:25 +01:00
parent 942482daab
commit b99dbf694d
40 changed files with 4184 additions and 0 deletions

55
app/templates/base.html Normal file
View 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;">&#128196; 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 &mdash; <a href="/healthz">health</a> &mdash; <a href="/metrics">metrics</a>
</footer>
</body>
</html>

View 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
&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 %}
{% else %}
running…
{% endif %}
</summary>
<p>Triggered by: <code>{{ last_run.triggered_by }}</code> &mdash; 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
View 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 %}

View 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 %}

View 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
View 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
View 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 %}