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 ────────────────────────────────────────────────────────────────────
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
PKG_NAME="dupfinder"
|
PKG_NAME="dupfinder"
|
||||||
PKG_VERSION="1.1.0"
|
PKG_VERSION="1.1.1"
|
||||||
PKG_ARCH="amd64"
|
PKG_ARCH="amd64"
|
||||||
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"
|
DEB_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb"
|
||||||
|
|
||||||
|
|||||||
@@ -725,6 +725,10 @@
|
|||||||
<div class="nav-item" data-view="export">
|
<div class="nav-item" data-view="export">
|
||||||
⇓ Export
|
⇓ Export
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-sep"></div>
|
||||||
|
<div class="nav-item" data-view="destinations">
|
||||||
|
↑ Destinations
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
@@ -898,9 +902,91 @@
|
|||||||
<div id="export-table-wrap"></div>
|
<div id="export-table-wrap"></div>
|
||||||
</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>
|
</main>
|
||||||
</div>
|
</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 -->
|
<!-- Folder picker -->
|
||||||
<div id="picker-overlay">
|
<div id="picker-overlay">
|
||||||
<div id="picker-box">
|
<div id="picker-box">
|
||||||
@@ -1013,6 +1099,7 @@ function switchView(view) {
|
|||||||
if (view === 'gallery') loadGallery(true);
|
if (view === 'gallery') loadGallery(true);
|
||||||
if (view === 'reviewed') loadReviewed();
|
if (view === 'reviewed') loadReviewed();
|
||||||
if (view === 'export') loadExport();
|
if (view === 'export') loadExport();
|
||||||
|
if (view === 'destinations') loadDestinations();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stats + topbar refresh ────────────────────────────────────────────────────
|
// ── Stats + topbar refresh ────────────────────────────────────────────────────
|
||||||
@@ -1601,6 +1688,163 @@ init();
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (state.scanStatus !== 'running') refreshStats();
|
if (state.scanStatus !== 'running') refreshStats();
|
||||||
}, 30000);
|
}, 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user