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:
2
debian/build-deb.sh
vendored
2
debian/build-deb.sh
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -725,6 +725,10 @@
|
||||
<div class="nav-item" data-view="export">
|
||||
⇓ Export
|
||||
</div>
|
||||
<div class="nav-sep"></div>
|
||||
<div class="nav-item" data-view="destinations">
|
||||
↑ 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()">✕</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----- ... -----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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|
||||
|
||||
Reference in New Issue
Block a user