Files
pngx-sync/app/templates/replicas.html
domverse b99dbf694d
All checks were successful
Deploy / deploy (push) Successful in 30s
feat: implement pngx-controller with Gitea CI/CD deployment
- 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>
2026-03-22 17:59:25 +01:00

194 lines
7.3 KiB
HTML

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