From a7b023c193f424005ff5820d478153764e3c19bc Mon Sep 17 00:00:00 2001 From: Carlos Date: Sun, 26 Apr 2026 20:29:22 -0400 Subject: [PATCH] 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 --- debian/build-deb.sh | 2 +- templates/index.html | 244 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/debian/build-deb.sh b/debian/build-deb.sh index b9e534f..3bd48f2 100644 --- a/debian/build-deb.sh +++ b/debian/build-deb.sh @@ -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" diff --git a/templates/index.html b/templates/index.html index 11e7c05..f5f4987 100644 --- a/templates/index.html +++ b/templates/index.html @@ -725,6 +725,10 @@ + + @@ -898,9 +902,91 @@
+ +
+
+
+

SFTP Destinations

+
+ Remote locations duplicates can be moved to. Move pipeline picks one of these per job. +
+
+ +
+
+
+ + + +
@@ -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 = '
Loading...
'; + try { + const dests = await api('GET', '/api/sftp/destinations'); + if (!dests.length) { + list.innerHTML = '
No destinations yet. Click "Add destination" above.
'; + 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 = ` +
+
+
+ ${statusIcon}${escapeHtml(d.name)} +
+
+ ${escapeHtml(d.username)}@${escapeHtml(d.host)}:${d.port} → ${escapeHtml(d.base_path)} +
+
+ auth: ${d.auth_method}${d.mirror_structure ? ' · mirrors structure' : ' · flat'} + ${d.last_tested_at ? ` · last tested ${d.last_tested_at}` : ''} +
+ ${d.last_test_result && d.last_test_result !== 'ok' + ? `
${escapeHtml(d.last_test_result)}
` + : ''} +
+
+ + + +
+
+ `; + list.appendChild(card); + }); + } catch (e) { + list.innerHTML = `
Failed to load: ${escapeHtml(e.message)}
`; + } +} + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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); + } +}