Files
pngx-sync/app/templates/replicas.html
domverse 7b1a85bc84
All checks were successful
Deploy / deploy (push) Successful in 39s
feat: full UI overhaul — dark theme with custom design system
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>
2026-03-26 22:52:42 +01:00

284 lines
12 KiB
HTML

{% extends "base.html" %}
{% block title %}Replicas — pngx-controller{% endblock %}
{% block content %}
<div class="page-hd">
<div>
<h2>Replicas</h2>
<div class="page-hd-sub">Paperless-ngx instances that receive documents from the master.</div>
</div>
</div>
{% if replicas %}
<div class="overflow-auto">
<table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Status</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 }}" style="font-weight:500;">{{ r.name }}</a></td>
<td><code>{{ r.url }}</code></td>
<td>
{% if r.suspended_at %}
<span class="badge badge-suspended">suspended</span>
<br><small class="muted">{{ r.consecutive_failures }} failures</small>
{% elif not r.enabled %}
<span class="badge badge-pending">disabled</span>
{% else %}
<span class="badge badge-ok">enabled</span>
{% endif %}
</td>
<td style="color:var(--text-2); font-size:.82rem;">
{% if r.sync_interval_seconds %}{{ r.sync_interval_seconds }}s{% else %}<small class="muted">global</small>{% endif %}
</td>
<td style="color:var(--text-2); font-size:.82rem;">
{% if r.last_sync_ts %}{{ r.last_sync_ts.strftime('%Y-%m-%d %H:%M') }} UTC{% else %}<small class="muted">never</small>{% endif %}
</td>
<td class="actions">
<a href="/replicas/{{ r.id }}" role="button" class="secondary outline" style="padding:.25em .65em; font-size:.78rem;">Detail</a>
<button class="outline" style="padding:.25em .65em; font-size:.78rem;"
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:.25em .65em; font-size:.78rem;"
hx-post="/api/replicas/{{ r.id }}/unsuspend"
hx-on::after-request="window.location.reload()">
Re-enable
</button>
{% endif %}
<button class="secondary outline" style="padding:.25em .65em; font-size:.78rem;"
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:.25em .65em; font-size:.78rem;"
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:.78rem;"></span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted" style="margin-bottom:1.5rem;">No replicas configured yet.</p>
{% endif %}
<h3>Add Replica</h3>
{% if env_replicas %}
<div class="env-detected">
<div class="env-detected-label">Detected in pngx.env &mdash; not yet added</div>
<div style="display:flex; flex-wrap:wrap; gap:.4rem; margin-top:.4rem;">
{% for er in env_replicas %}
{% set er_name = er.safe | lower | replace('_', '-') %}
<button type="button" class="outline" style="font-size:.8rem; padding:.3em .75em;"
onclick="prefillAdd('{{ er_name }}', '{{ er.url }}', '{{ er.token }}')">
+ {{ er.safe }} <span style="opacity:.5; margin-left:.2em; font-size:.75em;">{{ er.url }}</span>
</button>
{% endfor %}
</div>
</div>
{% endif %}
<div class="grid-form-sidebar">
<article style="margin:0;">
<p style="margin-bottom:1rem; font-size:.8rem; color:var(--text-3);">The controller will test the connection before saving.</p>
<form id="add-replica-form" onsubmit="submitAddReplica(event)">
<div class="grid-2">
<label>
Display name
<input type="text" name="name" required placeholder="e.g. berlin-office">
<small>Short identifier shown in the dashboard.</small>
</label>
<label>
Instance URL
<input type="url" name="url" required placeholder="http://100.x.x.x:8000">
<small>Must be reachable from this controller.</small>
</label>
</div>
<div class="grid-2" style="margin-top:.75rem;">
<label>
API token
<input type="password" name="api_token" required autocomplete="off">
<small>From Paperless &rarr; Settings &rarr; API &rarr; Auth Tokens.</small>
</label>
<label>
Sync interval
<input type="number" name="sync_interval_seconds" placeholder="Leave blank to use global" min="60">
<small>Seconds between syncs. Overrides the global setting.</small>
</label>
</div>
<div style="margin-top:.9rem;">
<label style="display:flex; align-items:center; gap:.5rem; cursor:pointer; font-size:.85rem;">
<input type="checkbox" name="enabled" checked>
Enable immediately after adding
</label>
</div>
<div id="add-test-result" style="margin:.75rem 0; min-height:1.5em;"></div>
<button type="submit" style="margin-bottom:0;">Add Replica</button>
</form>
</article>
<aside>
<article style="margin:0; padding:1.1rem;">
<p style="margin:0 0 .9rem; font-weight:600; font-size:.875rem; color:var(--text);">Before you add a replica</p>
<ol style="margin:0 0 1.1rem; padding-left:1.25rem; display:flex; flex-direction:column; gap:.65rem;">
<li style="font-size:.83rem; color:var(--text-2);">
<strong style="color:var(--text); display:block;">Verify reachability</strong>
The replica&rsquo;s URL must be reachable from this controller. On Tailscale, use the Tailscale IP and port.
</li>
<li style="font-size:.83rem; color:var(--text-2);">
<strong style="color:var(--text); display:block;">Generate an API token</strong>
In the replica&rsquo;s Paperless UI go to <strong>Settings &rarr; API &rarr; Auth Tokens</strong> and create a token for a superuser account.
</li>
<li style="font-size:.83rem; color:var(--text-2);">
<strong style="color:var(--text); display:block;">Decide on sync interval</strong>
Leave blank to use the global interval (set in Settings). Override per-replica if this instance needs a different cadence.
</li>
</ol>
<hr style="margin:.85rem 0;">
<p style="margin:0 0 .4rem; font-weight:600; font-size:.83rem; color:var(--text);">Replica already has documents?</p>
<p style="margin:0; font-size:.8rem; color:var(--text-2);">
Add it normally &mdash; then open its <strong>Detail</strong> page and run
<strong>Reconcile</strong> before the first sync. Reconcile matches
existing documents to the master without re-uploading them, preventing duplicates.
</p>
</article>
<article style="margin:.75rem 0 0; padding:.9rem 1.1rem; border-left:3px solid var(--accent);">
<p style="margin:0; font-size:.8rem; color:var(--text-2);">
<strong style="color:var(--text); display:block; margin-bottom:.2rem;">What gets synced?</strong>
Documents, tags, correspondents, document types, and custom fields.
The master always wins &mdash; replicas are read-only by convention.
</p>
</article>
</aside>
</div>
<!-- Edit modal -->
<dialog id="edit-modal">
<article>
<header>
<button aria-label="Close" onclick="document.getElementById('edit-modal').close()"></button>
<h3>Edit Replica</h3>
</header>
<form id="edit-replica-form" onsubmit="submitEditReplica(event)" style="padding:0;">
<div style="padding:1.15rem 1.4rem; display:flex; flex-direction:column; gap:.75rem;">
<input type="hidden" name="id">
<label>Display name <input type="text" name="name" required></label>
<label>Instance URL <input type="url" name="url" required></label>
<label>
API Token
<input type="password" name="api_token" placeholder="Leave blank to keep current" autocomplete="off">
<small>Only fill this if you want to update the token.</small>
</label>
<label>
Sync interval (seconds)
<input type="number" name="sync_interval_seconds" min="60" placeholder="Leave blank to use global">
</label>
<label style="display:flex; align-items:center; gap:.5rem; cursor:pointer; font-size:.85rem; margin-top:.1rem;">
<input type="checkbox" name="enabled"> Enabled
</label>
<div id="edit-test-result" style="min-height:1.5em;"></div>
</div>
<footer style="display:flex; justify-content:flex-end; gap:.5rem;">
<button type="button" class="secondary outline" onclick="document.getElementById('edit-modal').close()">Cancel</button>
<button type="submit">Save Changes</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');
const btn = form.querySelector('button[type=submit]');
btn.setAttribute('aria-busy', 'true');
btn.textContent = 'Testing connection\u2026';
const r = await fetch('/api/replicas', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
const json = await r.json();
btn.removeAttribute('aria-busy');
btn.textContent = 'Add Replica';
if (r.ok) {
resultEl.innerHTML = '<span class="badge badge-ok">&#10003; Connected &mdash; ' + (json.doc_count || 0) + ' documents found. Saving&hellip;</span>';
setTimeout(() => window.location.reload(), 900);
} else {
resultEl.innerHTML = '<span class="badge badge-error">&#10007; ' + (json.detail || 'Connection failed') + '</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');
const btn = form.querySelector('button[type=submit]');
btn.setAttribute('aria-busy', 'true');
btn.textContent = 'Saving\u2026';
const r = await fetch('/api/replicas/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
const json = await r.json();
btn.removeAttribute('aria-busy');
btn.textContent = 'Save Changes';
if (r.ok) {
resultEl.innerHTML = '<span class="badge badge-ok">&#10003; Saved</span>';
setTimeout(() => window.location.reload(), 700);
} else {
resultEl.innerHTML = '<span class="badge badge-error">&#10007; ' + (json.detail || 'Error') + '</span>';
}
}
</script>
{% endblock %}