Files
pngx-sync/app/templates/settings.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

170 lines
6.1 KiB
HTML

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