@@ -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);
+ }
+}