Redesign replicas page with professional B2B layout
All checks were successful
Deploy / deploy (push) Successful in 14s

- Two-column layout: add form (left) + prerequisites guide (right)
- Help sidebar with numbered checklist: reachability, API token, interval
- Callout for existing-instance reconcile workflow
- "What gets synced" info card with primary-color accent border
- Form fields with helper text under each input
- Spinner/busy state on submit buttons during API calls
- Clean status badges replacing emoji in replica table
- Better modal footer layout for edit dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 10:10:56 +01:00
parent 9c95f566b2
commit eee4d11f44

View File

@@ -2,7 +2,12 @@
{% block title %}Replicas — pngx-controller{% endblock %}
{% block content %}
<h2>Replicas</h2>
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem; margin-bottom:1.5rem;">
<div>
<h2 style="margin:0;">Replicas</h2>
<small class="muted">Paperless-ngx instances that receive documents from the master.</small>
</div>
</div>
{% if replicas %}
<div class="overflow-auto">
@@ -11,7 +16,7 @@
<tr>
<th>Name</th>
<th>URL</th>
<th>Enabled</th>
<th>Status</th>
<th>Interval</th>
<th>Last Sync</th>
<th>Actions</th>
@@ -21,16 +26,22 @@
{% 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><small><code>{{ r.url }}</code></small></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>
{% 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 %}
{% 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:0.2em 0.6em; font-size:0.8em;">Detail</a>
@@ -65,61 +76,137 @@
</table>
</div>
{% else %}
<p>No replicas yet.</p>
<p class="muted" style="margin-bottom:1.5rem;">No replicas configured yet.</p>
{% endif %}
<hr>
<hr style="margin:2rem 0;">
<h3>Add Replica</h3>
<h3 style="margin-bottom:0.25rem;">Add Replica</h3>
<p style="margin-top:0; margin-bottom:1.5rem;"><small class="muted">The controller will test the connection before saving.</small></p>
{% if env_replicas %}
<details open style="margin-bottom:1rem;">
<summary><strong>From pngx.env</strong> — {{ env_replicas|length }} not yet added</summary>
<div style="margin-bottom:1.25rem; padding:0.75rem 1rem; background:var(--pico-card-background-color); border:1px solid var(--pico-muted-border-color); border-radius:var(--pico-border-radius);">
<small style="font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--pico-muted-color);">Detected in pngx.env — not yet added</small>
<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;"
<button type="button" class="outline" style="font-size:0.85em; padding:0.3em 0.8em;"
onclick="prefillAdd('{{ er_name }}', '{{ er.url }}', '{{ er.token }}')">
+ {{ er.safe }} <small style="opacity:0.6;">{{ er.url }}</small>
+ {{ er.safe }} <small style="opacity:0.6; margin-left:0.25em;">{{ er.url }}</small>
</button>
{% endfor %}
</div>
</details>
</div>
{% endif %}
<div id="add-form-area">
<div style="display:grid; grid-template-columns:1fr 360px; gap:2rem; align-items:start;">
<!-- Add form -->
<article style="margin:0; padding:1.5rem;">
<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>
Display name
<input type="text" name="name" required placeholder="e.g. berlin-office">
<small class="muted">Short identifier shown in the dashboard.</small>
</label>
<label style="align-self:end;">
<input type="checkbox" name="enabled" checked> Enabled
<label>
Instance URL
<input type="url" name="url" required placeholder="http://100.x.x.x:8000">
<small class="muted">Must be reachable from this controller.</small>
</label>
</div>
<div id="add-test-result" style="margin:0.5rem 0;"></div>
<button type="submit">Add Replica</button>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin-top:0.5rem;">
<label>
API token
<input type="password" name="api_token" required autocomplete="off">
<small class="muted">From Paperless → Settings → API → Auth Tokens.</small>
</label>
<label>
Sync interval
<input type="number" name="sync_interval_seconds" placeholder="Leave blank to use global" min="60">
<small class="muted">Seconds between syncs. Overrides the global setting.</small>
</label>
</div>
<div style="margin-top:1rem;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="enabled" checked style="margin:0;">
Enable immediately after adding
</label>
</div>
<div id="add-test-result" style="margin:0.75rem 0; min-height:1.5em;"></div>
<button type="submit" style="margin-bottom:0;">Add Replica</button>
</form>
</article>
<!-- Help sidebar -->
<aside>
<article style="margin:0; padding:1.25rem; background:var(--pico-card-sectionning-background-color);">
<p style="margin:0 0 1rem; font-weight:600; font-size:0.95em;">Before you add a replica</p>
<ol style="margin:0 0 1.25rem; padding-left:1.25rem; display:flex; flex-direction:column; gap:0.75rem;">
<li>
<strong>Verify reachability</strong><br>
<small class="muted">The replica's URL must be reachable from this controller. On Tailscale, use the replica's Tailscale IP and port.</small>
</li>
<li>
<strong>Generate an API token</strong><br>
<small class="muted">In the replica's Paperless UI go to <strong>Settings → API → Auth Tokens</strong> and create a token for a superuser account.</small>
</li>
<li>
<strong>Decide on sync interval</strong><br>
<small class="muted">Leave blank to use the global interval (set in Settings). Override per-replica if this instance needs a different cadence.</small>
</li>
</ol>
<hr style="margin:1rem 0;">
<p style="margin:0 0 0.5rem; font-weight:600; font-size:0.9em;">Replica already has documents?</p>
<p style="margin:0; font-size:0.875em; color:var(--pico-muted-color);">
Add it normally — 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:0.75rem 0 0; padding:1rem 1.25rem; border-left:3px solid var(--pico-primary);">
<p style="margin:0; font-size:0.875em;">
<strong>What gets synced?</strong><br>
<span class="muted">Documents, tags, correspondents, document types, and custom fields.
The master always wins — replicas are read-only by convention.</span>
</p>
</article>
</aside>
</div>
<!-- Edit modal -->
<dialog id="edit-modal">
<article>
<header><h3>Edit Replica</h3></header>
<header>
<h3 style="margin:0;">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>
<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 class="muted">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:0.5rem; cursor:pointer;">
<input type="checkbox" name="enabled" style="margin:0;"> Enabled
</label>
<div id="edit-test-result" style="margin:0.5rem 0; min-height:1.5em;"></div>
<footer style="display:flex; justify-content:flex-end; gap:0.5rem; padding:0; margin-top:1rem;">
<button type="button" class="secondary outline" onclick="document.getElementById('edit-modal').close()">Cancel</button>
<button type="submit">Save Changes</button>
</footer>
</form>
</article>
@@ -144,14 +231,18 @@ async function submitAddReplica(e) {
sync_interval_seconds: data.sync_interval_seconds ? parseInt(data.sync_interval_seconds) : null
};
const resultEl = document.getElementById('add-test-result');
resultEl.textContent = 'Saving…';
const btn = form.querySelector('button[type=submit]');
btn.setAttribute('aria-busy', 'true');
btn.textContent = 'Testing connection…';
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">✓ Saved — doc count: ' + (json.doc_count || 0) + '</span>';
setTimeout(() => window.location.reload(), 800);
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"> ' + (json.detail || 'Error') + '</span>';
resultEl.innerHTML = '<span class="badge badge-error">&#10007; ' + (json.detail || 'Connection failed') + '</span>';
}
}
@@ -178,14 +269,18 @@ async function submitEditReplica(e) {
};
if (data.api_token) body.api_token = data.api_token;
const resultEl = document.getElementById('edit-test-result');
resultEl.textContent = 'Saving…';
const btn = form.querySelector('button[type=submit]');
btn.setAttribute('aria-busy', 'true');
btn.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();
btn.removeAttribute('aria-busy');
btn.textContent = 'Save Changes';
if (r.ok) {
resultEl.innerHTML = '<span class="badge badge-ok"> Saved</span>';
setTimeout(() => window.location.reload(), 600);
resultEl.innerHTML = '<span class="badge badge-ok">&#10003; Saved</span>';
setTimeout(() => window.location.reload(), 700);
} else {
resultEl.innerHTML = '<span class="badge badge-error"> ' + (json.detail || 'Error') + '</span>';
resultEl.innerHTML = '<span class="badge badge-error">&#10007; ' + (json.detail || 'Error') + '</span>';
}
}
</script>