Stage 2 #4: Destinations management UI

Adds 'Destinations' sidebar entry + view + add/edit/delete/test modal.
Generate-keypair button shows the public key for the user to paste into
the remote authorized_keys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos
2026-04-26 20:29:22 -04:00
parent 7436b23db3
commit a7b023c193
2 changed files with 245 additions and 1 deletions

2
debian/build-deb.sh vendored
View File

@@ -13,7 +13,7 @@ BUILD_DIR="$REPO_ROOT/build/deb"
# ── Config ────────────────────────────────────────────────────────────────────
PKG_NAME="dupfinder"
PKG_VERSION="1.1.0"
PKG_VERSION="1.1.1"
PKG_ARCH="amd64"
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"

View File

@@ -725,6 +725,10 @@
<div class="nav-item" data-view="export">
&#8659; Export
</div>
<div class="nav-sep"></div>
<div class="nav-item" data-view="destinations">
&#8593; Destinations
</div>
</nav>
<!-- Main -->
@@ -898,9 +902,91 @@
<div id="export-table-wrap"></div>
</div>
<!-- Destinations -->
<div id="view-destinations" class="view">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<div>
<h2 style="margin:0;">SFTP Destinations</h2>
<div class="text-dim" style="font-size:12px;margin-top:4px;">
Remote locations duplicates can be moved to. Move pipeline picks one of these per job.
</div>
</div>
<button class="btn-primary" onclick="openDestModal()">+ Add destination</button>
</div>
<div id="dest-list"></div>
</div>
</main>
</div>
<!-- Destination modal -->
<div id="dest-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;align-items:center;justify-content:center;">
<div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;width:560px;max-width:90vw;max-height:90vh;overflow:auto;padding:24px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 id="dest-modal-title" style="margin:0;">Add destination</h3>
<button class="btn-secondary btn-sm" onclick="closeDestModal()">&#10005;</button>
</div>
<input type="hidden" id="dest-id">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<label style="grid-column:span 2;">
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Name (display only)</div>
<input id="dest-name" type="text" placeholder="remote-quarantine" style="width:100%;">
</label>
<label>
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Host</div>
<input id="dest-host" type="text" placeholder="192.168.1.x" style="width:100%;">
</label>
<label>
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Port</div>
<input id="dest-port" type="number" value="22" style="width:100%;">
</label>
<label>
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Username</div>
<input id="dest-user" type="text" style="width:100%;">
</label>
<label>
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Auth method</div>
<select id="dest-auth" onchange="updateAuthFields()" style="width:100%;">
<option value="key">SSH key</option>
<option value="password">Password</option>
</select>
</label>
<label style="grid-column:span 2;">
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Base path on remote (where files land)</div>
<input id="dest-basepath" type="text" placeholder="/volume1/dupfinder-quarantine" style="width:100%;">
</label>
<label style="grid-column:span 2;display:flex;gap:6px;align-items:center;">
<input id="dest-mirror" type="checkbox" checked>
<span>Mirror source folder structure under base path</span>
</label>
<!-- Password auth field -->
<div id="dest-password-wrap" style="grid-column:span 2;display:none;">
<div class="text-dim" style="font-size:11px;margin-bottom:4px;">Password (leave blank when editing to keep existing)</div>
<input id="dest-password" type="password" style="width:100%;">
</div>
<!-- Key auth fields -->
<div id="dest-key-wrap" style="grid-column:span 2;">
<div style="display:flex;gap:8px;margin-bottom:6px;">
<button type="button" class="btn-secondary btn-sm" onclick="generateKeypair()">Generate new ED25519 keypair</button>
<span class="text-dim" style="font-size:11px;align-self:center;">or paste existing private key below</span>
</div>
<div id="dest-pubkey-wrap" style="display:none;background:var(--panel-2);padding:8px;border-radius:4px;margin-bottom:8px;font-size:11px;">
<div style="font-weight:600;margin-bottom:4px;">Add this public key to the remote ~/.ssh/authorized_keys:</div>
<code id="dest-pubkey" style="display:block;word-break:break-all;user-select:all;"></code>
</div>
<textarea id="dest-privkey" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" style="width:100%;font-family:monospace;font-size:11px;"></textarea>
<div class="text-dim" style="font-size:11px;margin-top:4px;">Leave blank when editing to keep existing key.</div>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">
<button class="btn-secondary" onclick="closeDestModal()">Cancel</button>
<button class="btn-primary" onclick="saveDest()">Save</button>
</div>
</div>
</div>
<!-- Folder picker -->
<div id="picker-overlay">
<div id="picker-box">
@@ -1013,6 +1099,7 @@ function switchView(view) {
if (view === 'gallery') loadGallery(true);
if (view === 'reviewed') loadReviewed();
if (view === 'export') loadExport();
if (view === 'destinations') loadDestinations();
}
// ── Stats + topbar refresh ────────────────────────────────────────────────────
@@ -1601,6 +1688,163 @@ init();
setInterval(() => {
if (state.scanStatus !== 'running') refreshStats();
}, 30000);
// ── SFTP destinations ───────────────────────────────────────────────────────
async function loadDestinations() {
const list = el('dest-list');
list.innerHTML = '<div class="text-dim">Loading...</div>';
try {
const dests = await api('GET', '/api/sftp/destinations');
if (!dests.length) {
list.innerHTML = '<div class="text-dim">No destinations yet. Click "Add destination" above.</div>';
return;
}
list.innerHTML = '';
dests.forEach(d => {
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--border);border-radius:6px;padding:14px;margin-bottom:10px;background:var(--panel);';
const statusIcon = d.last_test_result === 'ok' ? '✓' : (d.last_test_result ? '✗' : '?');
const statusColor = d.last_test_result === 'ok' ? '#3fb950' : (d.last_test_result ? '#f85149' : '#888');
card.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div style="flex:1;">
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">
<span style="color:${statusColor};margin-right:6px;">${statusIcon}</span>${escapeHtml(d.name)}
</div>
<div class="text-dim" style="font-size:12px;">
${escapeHtml(d.username)}@${escapeHtml(d.host)}:${d.port}${escapeHtml(d.base_path)}
</div>
<div class="text-dim" style="font-size:11px;margin-top:4px;">
auth: ${d.auth_method}${d.mirror_structure ? ' · mirrors structure' : ' · flat'}
${d.last_tested_at ? ` · last tested ${d.last_tested_at}` : ''}
</div>
${d.last_test_result && d.last_test_result !== 'ok'
? `<div style="font-size:11px;color:#f85149;margin-top:4px;">${escapeHtml(d.last_test_result)}</div>`
: ''}
</div>
<div style="display:flex;gap:6px;">
<button class="btn-secondary btn-sm" onclick="testDest(${d.id})">Test</button>
<button class="btn-secondary btn-sm" onclick="editDest(${d.id})">Edit</button>
<button class="btn-secondary btn-sm" onclick="deleteDest(${d.id})">Delete</button>
</div>
</div>
`;
list.appendChild(card);
});
} catch (e) {
list.innerHTML = `<div style="color:#f85149;">Failed to load: ${escapeHtml(e.message)}</div>`;
}
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
function openDestModal(dest) {
el('dest-overlay').style.display = 'flex';
el('dest-modal-title').textContent = dest ? 'Edit destination' : 'Add destination';
el('dest-id').value = dest ? dest.id : '';
el('dest-name').value = dest ? dest.name : '';
el('dest-host').value = dest ? dest.host : '';
el('dest-port').value = dest ? dest.port : 22;
el('dest-user').value = dest ? dest.username : '';
el('dest-auth').value = dest ? dest.auth_method : 'key';
el('dest-basepath').value = dest ? dest.base_path : '';
el('dest-mirror').checked = dest ? dest.mirror_structure : true;
el('dest-password').value = '';
el('dest-privkey').value = '';
el('dest-pubkey-wrap').style.display = 'none';
updateAuthFields();
}
function closeDestModal() {
el('dest-overlay').style.display = 'none';
}
function updateAuthFields() {
const method = el('dest-auth').value;
el('dest-password-wrap').style.display = method === 'password' ? 'block' : 'none';
el('dest-key-wrap').style.display = method === 'key' ? 'block' : 'none';
}
async function generateKeypair() {
try {
const r = await api('POST', '/api/sftp/keypair');
el('dest-privkey').value = r.private_key;
el('dest-pubkey').textContent = r.public_key;
el('dest-pubkey-wrap').style.display = 'block';
} catch (e) {
showToast('Failed to generate keypair: ' + e.message);
}
}
async function saveDest() {
const id = el('dest-id').value;
const body = {
name: el('dest-name').value.trim(),
host: el('dest-host').value.trim(),
port: parseInt(el('dest-port').value) || 22,
username: el('dest-user').value.trim(),
auth_method: el('dest-auth').value,
base_path: el('dest-basepath').value.trim(),
mirror_structure: el('dest-mirror').checked,
};
if (!body.name || !body.host || !body.username || !body.base_path) {
showToast('Name, host, username, and base path are required');
return;
}
if (body.auth_method === 'password') {
const pw = el('dest-password').value;
if (pw) body.password = pw;
else if (!id) { showToast('Password is required for new destinations'); return; }
} else {
const pk = el('dest-privkey').value.trim();
if (pk) body.private_key = pk;
else if (!id) { showToast('Private key is required for new destinations'); return; }
}
try {
if (id) await api('PUT', `/api/sftp/destinations/${id}`, body);
else await api('POST', '/api/sftp/destinations', body);
closeDestModal();
showToast('Saved');
loadDestinations();
} catch (e) {
showToast('Save failed: ' + e.message);
}
}
async function editDest(id) {
try {
const dests = await api('GET', '/api/sftp/destinations');
const d = dests.find(x => x.id === id);
if (d) openDestModal(d);
} catch (e) {
showToast('Failed: ' + e.message);
}
}
async function deleteDest(id) {
if (!confirm('Delete this destination? Stored credentials will also be removed.')) return;
try {
await api('DELETE', `/api/sftp/destinations/${id}`);
showToast('Deleted');
loadDestinations();
} catch (e) {
showToast('Delete failed: ' + e.message);
}
}
async function testDest(id) {
showToast('Testing...');
try {
const r = await api('POST', `/api/sftp/destinations/${id}/test`);
showToast(r.ok ? 'Connection OK' : 'Failed: ' + r.message);
loadDestinations();
} catch (e) {
showToast('Test failed: ' + e.message);
}
}
</script>
</body>
</html>