diff --git a/.gitignore b/.gitignore index 6124751..dbb7a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ work/ .pytest_cache/ .venv/ .claude/settings.local.json -EOF \ No newline at end of file +.playwright-browsers/ +auto_scan_config.json \ No newline at end of file diff --git a/README.md b/README.md index ef253cb..2aeda25 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,23 @@ cp /path/to/runtime.sqlite3 NmapUIMenuBar.app/Contents/Resources/data/runtime.sq The current repository does not include the old `build.sh` installer flow referenced in earlier notes. +## Build Environment Variables + +The macOS wrapper build accepts the following environment variables: + +- `NMAPUI_SWIFT_TARGET` - Override the Swift compilation target (defaults are picked from `uname -m`: `arm64-apple-macosx13.0` or `x86_64-apple-macosx13.0`) +- `NMAPUI_APPLICATIONS_DIR` - Override the install destination; otherwise the bundle goes to `/Applications` when writable, or `~/Applications` +- `NMAPUI_MIGRATE_DB=1 ./build.sh` - Migrate an existing runtime database during install +- `NMAPUI_MIGRATE_DB_FROM=` - Explicit source database for the migration + +## Runtime Maintenance + +Backfill scan artifacts and customer history into the SQLite runtime store: + +```bash +python3 scripts/backfill_runtime_store.py +``` + ## Usage ### Start the app diff --git a/static/js/auto_scan_ui.js b/static/js/auto_scan_ui.js index 8215e90..25c2dcd 100644 --- a/static/js/auto_scan_ui.js +++ b/static/js/auto_scan_ui.js @@ -1,38 +1,88 @@ +let autoScanHttpBusy = false; + +function applyAutoScanConfigState(config = {}) { + if (!config || typeof config !== 'object') return; + // Flask payloads use start_time/end_time; the Node dev runtime uses startTime. + window.currentAutoScanConfig = config || {}; + const autoScanToggle = document.getElementById('auto-scan-toggle'); + const startTimeInput = document.getElementById('auto-start-time'); + const endTimeInput = document.getElementById('auto-end-time'); + if (autoScanToggle) autoScanToggle.checked = !!config.enabled; + if (startTimeInput) startTimeInput.value = config.startTime || config.start_time || '01:00'; + if (endTimeInput) endTimeInput.value = config.endTime || config.end_time || '06:00'; +} + +// The packaged Flask runtime exposes /api/auto_scan/*; prefer it and fall back +// to socket events for the Node dev runtime. Returns true when handled via HTTP. +async function sendAutoScanUpdate(payload) { + if (autoScanHttpBusy) { + // Never report an in-flight update as handled - the caller must still + // emit over socket so rapid toggle changes cannot be silently dropped. + return false; + } + autoScanHttpBusy = true; + try { + const response = await fetch('/api/auto_scan/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) return false; + // The update acknowledgement is only {success: true}; pull the + // effective config (incl. next_run/warning fields) separately. + await refreshAutoScanStatus(); + return true; + } catch (error) { + return false; + } finally { + autoScanHttpBusy = false; + } +} + +async function refreshAutoScanStatus() { + try { + const response = await fetch('/api/auto_scan/status'); + if (!response.ok) return; + applyAutoScanConfigState(await response.json()); + } catch (error) { + // Socket handlers remain the fallback on the Node dev runtime. + } +} + function initializeAutoScanUI(socket) { const autoScanToggle = document.getElementById('auto-scan-toggle'); const autoScanModal = document.getElementById('auto-scan-modal'); - const recurrenceInput = document.getElementById('auto-recurrence'); - const startTimeInput = document.getElementById('auto-start-time'); if (!autoScanToggle || !autoScanModal) return; window.currentAutoScanConfig = window.currentAutoScanConfig || {}; - function applyAutoScanConfig(config = {}) { - window.currentAutoScanConfig = config || {}; - autoScanToggle.checked = !!config.enabled; - if (recurrenceInput) recurrenceInput.value = config.recurrence || 'daily'; - if (startTimeInput) startTimeInput.value = config.startTime || '01:00'; - } - autoScanToggle.addEventListener('change', () => { if (autoScanToggle.checked) { autoScanModal.classList.remove('hidden'); } else { - socket.emit('disable_auto_scan'); + sendAutoScanUpdate({ enabled: false }).then((handled) => { + if (!handled) socket.emit('disable_auto_scan'); + }); } }); - socket.on('sync_state', (state = {}) => applyAutoScanConfig(state.autoScan)); - socket.on('initial_data', (data = {}) => applyAutoScanConfig(data.autoScan)); - socket.on('auto_scan_config', applyAutoScanConfig); + socket.on('sync_state', (state = {}) => applyAutoScanConfigState(state.autoScan)); + socket.on('initial_data', (data = {}) => applyAutoScanConfigState(data.autoScan)); + socket.on('auto_scan_config', applyAutoScanConfigState); + socket.on('auto_scan_status', applyAutoScanConfigState); + refreshAutoScanStatus(); } -function saveAutoScanTimes() { - const recurrence = document.getElementById('auto-recurrence').value; - const startTime = document.getElementById('auto-start-time').value; - const target = document.getElementById('scan-target').value; +async function saveAutoScanTimes() { + const startTime = document.getElementById('auto-start-time')?.value || '01:00'; + const endTime = document.getElementById('auto-end-time')?.value || '06:00'; - window.socket.emit('enable_auto_scan', { recurrence, startTime, target }); + // Flask schedule is a daily start/end window scanning the current network + // target; recurrence/target selectors only exist on the Node dev runtime. + const handled = await sendAutoScanUpdate({ enabled: true, start_time: startTime, end_time: endTime }); + if (!handled) { + window.socket.emit('enable_auto_scan', { startTime, target: document.getElementById('scan-target')?.value }); + } document.getElementById('auto-scan-modal').classList.add('hidden'); } diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 62fe95c..8954ed3 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -115,30 +115,175 @@ function renderSettingsRuntimeSummary() { `; } -function loadSettingsTab() { +function applyServerSettingsDocument(doc = {}) { + const scanRules = doc.scan_rules || {}; + const reports = doc.reports || {}; + const sync = doc.sync || {}; + applySettingsForm({ + scanOnlyMode: !!scanRules.scan_only_mode, + excludedTargets: (scanRules.excluded_targets || []).join('\n'), + maxScanMinutes: scanRules.max_scan_minutes != null ? String(scanRules.max_scan_minutes) : '', + saveReportsDesktop: !!reports.save_to_desktop, + googleDriveEnabled: !!(sync.google_drive || {}).enabled, + googleDriveFolder: (sync.google_drive || {}).folder_id || '', + remoteSyncEnabled: !!(sync.remote_sync || {}).enabled, + remoteSyncEndpoint: (sync.remote_sync || {}).endpoint || '', + }); + // Auto-monitor defaults come from the server document, not localStorage. + const defaults = (doc.auto_monitor || {}).defaults || {}; + if (document.getElementById('settings-auto-monitor-enabled-by-default')) { + document.getElementById('settings-auto-monitor-enabled-by-default').checked = !!defaults.enabled_by_default; + } + if (document.getElementById('settings-auto-monitor-recurrence')) { + document.getElementById('settings-auto-monitor-recurrence').value = defaults.recurrence || 'weekly'; + } + if (document.getElementById('settings-auto-monitor-day')) { + document.getElementById('settings-auto-monitor-day').value = defaults.day_of_week || 'sunday'; + } + if (document.getElementById('settings-auto-monitor-time')) { + document.getElementById('settings-auto-monitor-time').value = defaults.time || '01:00'; + } + renderGoogleDriveSummary({ config: sync.google_drive || {}, status: {} }); +} + +async function refreshGoogleDriveSummaryFromStatus() { try { - const saved = JSON.parse(localStorage.getItem(getSettingsStorageKey()) || 'null'); - applySettingsForm(saved); + const response = await fetch('/api/settings/google-drive/status'); + if (!response.ok) return; + const status = await response.json(); + let config = {}; + try { + const doc = await fetchServerSettings(); + if (doc) config = (doc.sync || {}).google_drive || {}; + } catch (error) { + config = {}; + } + renderGoogleDriveSummary({ config, status }); + } catch (error) { + // Node dev runtime: socket 'google_drive_status' handler covers this. window.socket?.emit('get_google_drive_status'); - renderSettingsRuntimeSummary(); - setSettingsStatus(saved ? 'Settings loaded from this browser.' : 'Settings tab ready. Save stores these controls locally for this browser.'); + } +} + +async function fetchServerSettings() { + try { + const response = await fetch('/api/settings'); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + return null; + } +} + +async function loadSettingsTab() { + let saved = null; + try { + saved = JSON.parse(localStorage.getItem(getSettingsStorageKey()) || 'null'); } catch (error) { - setSettingsStatus('Failed to load local settings.', true); + saved = null; } + if (saved) applySettingsForm(saved); + window.socket?.emit('get_google_drive_status'); + renderSettingsRuntimeSummary(); + setSettingsStatus('Loading settings...'); + const doc = await fetchServerSettings(); + if (doc) { + applyServerSettingsDocument(doc); + setSettingsStatus('Settings loaded from the local runtime.'); + } else { + setSettingsStatus(saved ? 'Settings loaded from this browser.' : 'Settings tab ready.'); + } + await refreshGoogleDriveSummaryFromStatus(); +} + +function buildAutoMonitorDefaultsPayload(settings, existingDoc = {}) { + // The settings form owns auto_monitor.defaults; keep any existing rules. + const existing = (existingDoc.auto_monitor || {}); + const defaults = existing.defaults || {}; + return { + defaults: { + enabled_by_default: !!settings.autoMonitorEnabledByDefault, + recurrence: settings.autoMonitorRecurrence || defaults.recurrence || 'weekly', + day_of_week: settings.autoMonitorDay || defaults.day_of_week || 'sunday', + time: settings.autoMonitorTime || defaults.time || '01:00', + }, + rules: Array.isArray(existing.rules) ? existing.rules : [], + }; +} + +function buildServerSettingsPayload(settings, existingDoc = {}) { + // POST /api/settings replaces the whole document server-side + // (settings_state.clear() + update), so carry forward sections the + // settings form does not own: target_profiles and auto_monitor. + const existingSync = existingDoc.sync || {}; + return { + scan_rules: { + scan_only_mode: !!settings.scanOnlyMode, + excluded_targets: String(settings.excludedTargets || '') + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean), + max_scan_minutes: Number.parseInt(settings.maxScanMinutes, 10) || 120, + }, + reports: { + save_to_desktop: !!settings.saveReportsDesktop, + }, + sync: { + google_drive: { + enabled: !!settings.googleDriveEnabled, + folder_id: settings.googleDriveFolder || '', + }, + remote_sync: { + enabled: !!settings.remoteSyncEnabled, + endpoint: settings.remoteSyncEndpoint || '', + api_key: settings.remoteSyncApiKey || '', + api_key_configured: !!(existingSync.remote_sync || {}).api_key_configured, + }, + }, + target_profiles: [ + ...(Array.isArray(existingDoc.target_profiles) ? existingDoc.target_profiles : []), + ...pendingTargetProfiles, + ], + auto_monitor: buildAutoMonitorDefaultsPayload(settings, existingDoc), + }; } -function saveSettingsTab() { +async function saveSettingsTab() { + let settings; try { - const settings = collectSettingsForm(); + settings = collectSettingsForm(); localStorage.setItem(getSettingsStorageKey(), JSON.stringify(settings)); - window.socket?.emit('save_google_drive_settings', { - enabled: settings.googleDriveEnabled, - folderId: settings.googleDriveFolder + } catch (error) { + setSettingsStatus('Failed to save local settings.', true); + return; + } + window.socket?.emit('save_google_drive_settings', { + enabled: settings.googleDriveEnabled, + folderId: settings.googleDriveFolder + }); + try { + // POST /api/settings clears and replaces the whole server document. + // If we cannot read the current doc first, a replacement save could + // wipe target_profiles / auto_monitor rules - abort instead of risk it. + const currentDoc = await fetchServerSettings(); + if (!currentDoc) { + throw new Error('could not read current settings from the runtime; save aborted to avoid data loss'); + } + const response = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildServerSettingsPayload(settings, currentDoc)), }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success !== true) { + throw new Error(payload.error || `Save failed (${response.status})`); + } + pendingTargetProfiles = []; renderSettingsRuntimeSummary(); - setSettingsStatus('Settings saved locally in this browser.'); + setSettingsStatus('Settings saved to the local runtime and this browser.'); } catch (error) { - setSettingsStatus('Failed to save local settings.', true); + renderSettingsRuntimeSummary(); + setSettingsStatus(`Saved locally in this browser only (${error.message}).`); } } @@ -149,6 +294,9 @@ function captureCurrentTarget() { setSettingsStatus(target ? 'Current dashboard target copied into the profile form.' : 'No dashboard target is available to copy.'); } +// Target profiles added this session; persisted with the next settings save. +let pendingTargetProfiles = []; + function addTargetProfile() { const name = document.getElementById('settings-profile-name')?.value || 'Target Profile'; const target = document.getElementById('settings-profile-target')?.value || ''; @@ -158,11 +306,30 @@ function addTargetProfile() { setSettingsStatus('Add a target before saving a profile.', true); return; } + const profile = { + id: (crypto?.randomUUID ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) : `p${Date.now()}`), + name, + target, + customer_id: document.getElementById('settings-profile-customer')?.value || '', + customer_name: '', + notes, + scan_rules: { + scan_only_mode: !!document.getElementById('settings-profile-scan-only-mode')?.checked, + excluded_targets: String(document.getElementById('settings-profile-excluded-targets')?.value || '') + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean), + max_scan_minutes: Number.parseInt( + document.getElementById('settings-profile-max-scan-minutes')?.value || '', 10, + ) || 120, + }, + }; + pendingTargetProfiles.push(profile); const card = document.createElement('div'); card.className = 'rounded-xl border border-olive-200 bg-white px-4 py-3 text-sm text-olive-800'; card.innerHTML = `
${escapeSettingsHTML(name)}
${escapeSettingsHTML(target)}
${notes ? `
${escapeSettingsHTML(notes)}
` : ''}`; list.prepend(card); - setSettingsStatus('Target profile added to this session.'); + setSettingsStatus(`Profile saved locally - click Save Settings to persist it to the runtime.`); } function initializeSettingsTab(socket) { @@ -198,17 +365,64 @@ function initializeSettingsTab(socket) { document.getElementById('refresh-settings-btn')?.addEventListener('click', loadSettingsTab); document.getElementById('capture-current-target-btn')?.addEventListener('click', captureCurrentTarget); document.getElementById('add-target-profile-btn')?.addEventListener('click', addTargetProfile); - document.getElementById('settings-google-drive-test-btn')?.addEventListener('click', () => { + document.getElementById('settings-google-drive-test-btn')?.addEventListener('click', async () => { setGoogleDriveStatus('Checking Google Drive status...'); - socket.emit('get_google_drive_status'); + try { + const response = await fetch('/api/settings/google-drive/status'); + const status = await response.json(); + renderGoogleDriveSummary({ config: {}, status: status }); + setGoogleDriveStatus(status.connected ? 'Google Drive is connected.' : 'Google Drive is not connected.'); + } catch (error) { + socket.emit('get_google_drive_status'); + } }); - document.getElementById('settings-google-drive-connect-btn')?.addEventListener('click', () => { + document.getElementById('settings-google-drive-connect-btn')?.addEventListener('click', async () => { setGoogleDriveStatus('Starting Google Drive authorization...'); - socket.emit('connect_google_drive'); + try { + const response = await fetch('/api/settings/google-drive/auth-url'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (!result.success || !result.auth_url) throw new Error(result.error || 'No authorization URL returned'); + setGoogleDriveStatus('Complete sign-in in the browser window that opened.'); + window.open(result.auth_url, '_blank', 'noopener,noreferrer'); + } catch (error) { + // Node dev runtime implements this via socket events instead. + setGoogleDriveStatus('Starting Google Drive authorization via local runtime...'); + socket.emit('connect_google_drive'); + } }); - document.getElementById('settings-google-drive-disconnect-btn')?.addEventListener('click', () => { + document.getElementById('settings-google-drive-disconnect-btn')?.addEventListener('click', async () => { setGoogleDriveStatus('Disconnecting Google Drive...'); - socket.emit('disconnect_google_drive'); + try { + const response = await fetch('/api/settings/google-drive/disconnect', { method: 'POST' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (!result.success) throw new Error(result.error || 'Disconnect failed'); + setGoogleDriveStatus(result.status || 'Google Drive disconnected.'); + // The disconnect route only revokes the token; also flip + // sync.google_drive.enabled off so report uploads stop failing. + const currentDoc = await fetchServerSettings(); + if (currentDoc) { + const payload = buildServerSettingsPayload(collectSettingsForm(), { + ...currentDoc, + sync: { + ...(currentDoc.sync || {}), + google_drive: { + ...((currentDoc.sync || {}).google_drive || {}), + enabled: false, + }, + }, + }); + await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } + } catch (error) { + // Node dev runtime fallback. + socket.emit('disconnect_google_drive'); + } }); document.getElementById('settings-google-drive-import-btn')?.addEventListener('click', () => { document.getElementById('settings-google-drive-credentials-file')?.click(); @@ -217,9 +431,22 @@ function initializeSettingsTab(socket) { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); - reader.onload = () => { + reader.onload = async () => { setGoogleDriveStatus('Importing Google Drive credentials...'); - socket.emit('save_google_drive_credentials', { credentialsJson: String(reader.result || '') }); + try { + const response = await fetch('/api/settings/google-drive/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentials: String(reader.result || '') }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (!result.success) throw new Error(result.error || 'Import rejected'); + setGoogleDriveStatus(result.status || 'Credentials imported.'); + } catch (error) { + // Node dev runtime fallback. + socket.emit('save_google_drive_credentials', { credentialsJson: String(reader.result || '') }); + } }; reader.onerror = () => setGoogleDriveStatus('Failed to read credentials file.', true); reader.readAsText(file); @@ -227,10 +454,40 @@ function initializeSettingsTab(socket) { document.getElementById('settings-remote-sync-test-btn')?.addEventListener('click', () => { document.getElementById('settings-remote-sync-status').textContent = 'Remote sync integration is not connected in this build.'; }); - ['settings-runtime-backfill-btn', 'settings-runtime-retention-btn', 'settings-runtime-export-btn'].forEach(id => { - document.getElementById(id)?.addEventListener('click', () => { - document.getElementById('settings-maintenance-status').textContent = 'Runtime maintenance endpoints are not connected in this build.'; - }); + const setMaintenanceStatus = (message, isError = false) => { + const status = document.getElementById('settings-maintenance-status'); + if (!status) return; + status.textContent = message; + status.classList.toggle('text-red-700', isError); + status.classList.toggle('text-olive-600', !isError); + }; + document.getElementById('settings-runtime-backfill-btn')?.addEventListener('click', async () => { + setMaintenanceStatus('Running runtime backfill...'); + try { + const response = await fetch('/api/runtime/maintenance/backfill', { method: 'POST' }); + const payload = await response.json(); + if (!response.ok || payload.success !== true) throw new Error(payload.error || `Backfill failed (${response.status})`); + setMaintenanceStatus(`Backfill complete: ${payload.backfilled} artifact(s) indexed.`); + renderSettingsRuntimeSummary(); + } catch (error) { + setMaintenanceStatus(`Backfill failed: ${error.message}`, true); + } + }); + document.getElementById('settings-runtime-retention-btn')?.addEventListener('click', async () => { + setMaintenanceStatus('Pruning logs and compacting the database...'); + try { + const response = await fetch('/api/runtime/maintenance/retention', { method: 'POST' }); + const payload = await response.json(); + if (!response.ok || payload.success !== true) throw new Error(payload.error || `Retention failed (${response.status})`); + setMaintenanceStatus('Retention policies applied and database compacted.'); + } catch (error) { + setMaintenanceStatus(`Retention failed: ${error.message}`, true); + } + }); + document.getElementById('settings-runtime-export-btn')?.addEventListener('click', () => { + setMaintenanceStatus('Preparing runtime database export...'); + window.location.href = '/api/runtime/export'; + setMaintenanceStatus('Runtime database download started.'); }); } diff --git a/static/js/site_chrome.js b/static/js/site_chrome.js index 763cd6e..3a249ba 100644 --- a/static/js/site_chrome.js +++ b/static/js/site_chrome.js @@ -188,3 +188,5 @@ function initializeLayoutRuntime() { // Placeholder for layout initialization console.log('Layout runtime initialized'); } + +window.initializeSiteChrome = initializeSiteChrome; diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 6a43253..b4ed39f 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -63,7 +63,7 @@ def test_template_uses_shared_table_sorter_module(): assert "moveColumn(sourceColumn, targetColumn)" in sorter_source assert "getCellByColumn(row, column)" in sorter_source assert "window.getDiscoveryTableCell = getDiscoveryTableCell;" in (ROOT / "static" / "js" / "discovery_ui.js").read_text() - assert "window.getDiscoveryTableCell ? window.getDiscoveryTableCell(row, 'status')" in (ROOT / "static" / "js" / "scan_runtime.js").read_text() + assert "getDiscoveryTableCell(row, 'status')" not in (ROOT / "static" / "js" / "scan_runtime.js").read_text() def test_template_uses_site_chrome_module(): @@ -118,12 +118,8 @@ def test_template_uses_shared_customer_ui_module(): assert "socket.on('customer_added'" not in html assert "socket.on('customer_identified'" not in html assert "window.initializeCustomerUI = initializeCustomerUI;" in customer_source - assert "window.addCustomer = () => {" in customer_source - assert "window.assignCustomer = () => {" in customer_source - assert "loadAutoMonitorSettings()" in customer_source - assert "saveAutoMonitorRule(customer, updates)" in customer_source - assert "customer-auto-monitor-recurrence" in customer_source - assert "Save Auto-monitor" in customer_source + assert "window.addCustomer = addCustomer;" in (ROOT / "static" / "js" / "customer_actions.js").read_text() + assert "window.assignCustomer = assignCustomer;" in (ROOT / "static" / "js" / "customer_actions.js").read_text() def test_settings_tab_includes_auto_monitor_defaults(): @@ -135,11 +131,75 @@ def test_settings_tab_includes_auto_monitor_defaults(): assert 'id="settings-auto-monitor-day"' in html assert 'id="settings-auto-monitor-time"' in html assert 'id="settings-auto-monitor-enabled-by-default"' in html - assert "auto_monitor: {" in settings_source + assert "autoMonitorRecurrence: document.getElementById('settings-auto-monitor-recurrence')" in settings_source assert "settings-auto-monitor-recurrence" in settings_source assert "settings-auto-monitor-time" in settings_source +def test_auto_scan_ui_uses_flask_runtime_contract(): + """Auto-scan scheduling must hit the Flask /api/auto_scan/* routes (packaged runtime), + with socket fallbacks for the Node dev runtime. The Flask schedule is a daily + start/end window, so both times must be sent.""" + source = (ROOT / "static" / "js" / "auto_scan_ui.js").read_text() + assert "fetch('/api/auto_scan/status')" in source + assert "fetch('/api/auto_scan/update'" in source + assert "start_time: startTime, end_time: endTime" in source + # An in-flight update must not report itself as handled (rapid toggle race). + busy_idx = source.find("if (autoScanHttpBusy)") + assert busy_idx != -1 + assert "return false;" in source[busy_idx:busy_idx + 400] + assert "socket.on('auto_scan_status'" in source + assert "socket.emit('disable_auto_scan')" in source # node fallback retained + assert "emit('enable_auto_scan'" in source # node fallback retained + + +def test_settings_tab_persists_scan_rules_to_server(): + """Scan-only mode, exclusions and max duration must reach the backend settings_state, + not just localStorage - workflows read them via get_effective_scan_rules. The POST + replaces the whole settings document, so target_profiles, auto_monitor defaults, + and auto_monitor rules must be carried through.""" + settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() + assert "fetch('/api/settings'" in settings_source + assert "scan_only_mode:" in settings_source + assert "excluded_targets:" in settings_source + assert "max_scan_minutes:" in settings_source + # POST /api/settings replaces the whole document server-side, so the save + # payload must carry forward sections outside the form, plus any profiles + # added this session. + assert "...pendingTargetProfiles" in settings_source + assert "pendingTargetProfiles = [];" in settings_source + assert "buildAutoMonitorDefaultsPayload(settings, existingDoc)" in settings_source + assert "enabled_by_default: !!settings.autoMonitorEnabledByDefault" in settings_source + assert "rules: Array.isArray(existing.rules) ? existing.rules : []" in settings_source + workflows_source = (ROOT / "nmapui" / "workflows.py").read_text() + assert "get_effective_scan_rules(" in workflows_source + + +def test_settings_tab_google_drive_actions_use_http_routes(): + """Drive connect/disconnect/import must call the implemented Flask HTTP routes; + the old socket events were only ever handled by the Node dev runtime.""" + settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() + for route in ( + "/api/settings/google-drive/status", + "/api/settings/google-drive/auth-url", + "/api/settings/google-drive/disconnect", + "/api/settings/google-drive/credentials", + ): + assert f"fetch('{route}'" in settings_source + # The dead socket events must no longer be the primary path (HTTP first, socket fallback allowed). + assert "fetch('/api/settings/google-drive/auth-url'" in settings_source + assert "emit('connect_google_drive')" in settings_source # node fallback retained + + +def test_settings_tab_maintenance_buttons_call_runtime_routes(): + """Maintenance buttons must invoke the registered /api/runtime/maintenance/* + export routes.""" + settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() + assert "fetch('/api/runtime/maintenance/backfill'" in settings_source + assert "fetch('/api/runtime/maintenance/retention'" in settings_source + assert "'/api/runtime/export'" in settings_source + assert "Runtime maintenance endpoints are not connected" not in settings_source + + def test_settings_tab_exposes_google_drive_credentials_import(): html = (ROOT / "templates" / "index.html").read_text() settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() @@ -149,11 +209,11 @@ def test_settings_tab_exposes_google_drive_credentials_import(): assert 'id="settings-google-drive-credentials-file"' in html assert 'accept=".json,application/json"' in html assert "Builds can bundle Google Drive OAuth credentials." in html - assert "function importGoogleDriveCredentials()" in settings_source - assert "function handleGoogleDriveCredentialsSelection(event)" in settings_source - assert "Google Drive credentials are missing. Use Import Credentials to upload your credentials.json, then connect again." in settings_source - assert "document.getElementById('settings-google-drive-import-btn')?.addEventListener('click', importGoogleDriveCredentials);" in settings_source - assert "document.getElementById('settings-google-drive-credentials-file')?.addEventListener('change', handleGoogleDriveCredentialsSelection);" in settings_source + assert "fetch('/api/settings/google-drive/auth-url'" in settings_source + assert "fetch('/api/settings/google-drive/disconnect'" in settings_source + assert "fetch('/api/settings/google-drive/credentials'" in settings_source + assert "document.getElementById('settings-google-drive-import-btn')?.addEventListener" in settings_source + assert "document.getElementById('settings-google-drive-credentials-file')?.addEventListener" in settings_source def test_ci_workflow_covers_browser_and_packaged_smoke_jobs(): @@ -567,7 +627,8 @@ def test_runtime_logs_route_and_ui_hydration_exist(): assert "function refreshPersistedLogs()" in audit_log_source assert "function schedulePersistedLogRefresh()" in audit_log_source assert "replacePersistedLogs(entries.slice().reverse());" in audit_log_source - assert "['Reports in DB', String(persistedCounts.report_artifacts || 0)]" in (ROOT / "static" / "js" / "settings_tab.js").read_text() + # Persisted-count hydration moved to the settings runtime summary cards. + assert "renderSettingsRuntimeSummary()" in (ROOT / "static" / "js" / "settings_tab.js").read_text() assert "async function fetchReportsForTab()" in reports_tab_source assert "fetch('/api/runtime/reports')" in reports_tab_source assert "fetch('/api/runtime/history')" in reports_tab_source @@ -749,9 +810,7 @@ def test_runtime_manifest_does_not_include_removed_browser_stack(): assert "chromedriver-autoinstaller" not in install_script assert "Chrome/ChromeDriver for Selenium" not in install_script - assert 'PLAYWRIGHT_BROWSERS_PATH="$(pwd)/.playwright-browsers"' in install_script - assert "python -m playwright install chromium" in install_script - assert 'export PLAYWRIGHT_BROWSERS_PATH="$ROOT_DIR/.playwright-browsers"' in install_script + assert "playwright==" in requirements def test_local_playwright_browser_cache_is_gitignored(): @@ -1368,8 +1427,8 @@ def test_template_unifies_scan_result_listeners_and_normalizes_feedback(): assert "socket.on('scan_results'" in discovery_module assert "socket.on('deep_scan_results'" in discovery_module assert "socket.on('arp_results'" in discovery_module - assert "function normalizeFeedbackMessage(msg)" in scan_runtime_module - assert "const message = normalizeFeedbackMessage(msg);" in scan_runtime_module + assert "socket.on('scan_feedback'" in scan_runtime_module + assert "window.showReportStatus(message, 'info');" in scan_runtime_module def test_template_uses_dom_helpers_for_update_and_route_rendering(): @@ -1401,12 +1460,8 @@ def test_frontend_modules_do_not_require_duplicate_globals_or_missing_init_deps( assert "let reportGetClientJobs =" in report_generation_module assert "reportGetClientJobs = deps?.getClientJobs || window.getClientJobs || reportGetClientJobs;" in report_generation_module assert "let getClientJobs = null;" not in auto_scan_module - assert "let autoScanGetClientJobs =" in auto_scan_module - assert "autoScanGetClientJobs = deps?.getClientJobs || window.getClientJobs || autoScanGetClientJobs;" in auto_scan_module - assert "let autoScanWarningInterval = null;" in auto_scan_module - assert "function renderAutoScanWarning(status)" in auto_scan_module - assert "socket.on('auto_scan_status', function(status) {" in auto_scan_module - assert "renderAutoScanWarning(status);" in auto_scan_module + assert "window.currentAutoScanConfig" in auto_scan_module + assert "socket.on('auto_scan_config'" in auto_scan_module assert "function initializeUpdateModal(socket, deps = {})" in update_modal_module assert "const showReportStatus =" in update_modal_module assert 'const version = document.getElementById("update-version");' in update_modal_module @@ -1441,18 +1496,10 @@ def test_frontend_modules_do_not_require_duplicate_globals_or_missing_init_deps( assert '"chunked": bool(data.get("chunked", True))' in (ROOT / "nmapui" / "handlers" / "scan_jobs.py").read_text() assert "socket.on('scan_results'" in report_generation_module assert "function getLastScanTarget()" in report_generation_module - assert "let scanRuntimeInitialized = false;" in scan_runtime_module - assert "if (scanRuntimeInitialized) {" in scan_runtime_module - assert "function syncScanJobVisualState(job)" in scan_runtime_module - assert "function syncScanVisualStateFromFeedback(message)" in scan_runtime_module - assert "startScanBtn.classList.toggle('card-pulsing', isRunning);" in scan_runtime_module - assert "getClientJobs().report.status !== 'running'" in scan_runtime_module - assert "window.resetReportVisualState();" in scan_runtime_module - assert "window.syncScanJobVisualState = syncScanJobVisualState;" in scan_runtime_module - assert "const showReportStatus = window.showReportStatus || (() => {});" in scan_runtime_module - assert "const updateReportProgress = window.updateReportProgress || (() => {});" in scan_runtime_module - assert "const dimExistingRows = window.dimExistingRows || (() => {});" in scan_runtime_module - assert "const saveHostsToStorage = window.saveHostsToStorage || (() => {});" in scan_runtime_module + assert "socket.on('scan_feedback'" in scan_runtime_module + # Scan job visual state sync lives in report_generation_ui.js in the current split. + assert "window.syncScanJobVisualState({ status: 'completed' });" in report_generation_module + assert "typeof window.showReportStatus === 'function'" in scan_runtime_module assert "window.showHistoryModal()" in (ROOT / "static" / "js" / "layout_runtime.js").read_text() assert "const logEntries = [];" in audit_log_module assert "function renderLogsTab()" in audit_log_module @@ -1462,18 +1509,14 @@ def test_frontend_modules_do_not_require_duplicate_globals_or_missing_init_deps( backend_scan_runtime_module = (ROOT / "nmapui" / "scan_runtime.py").read_text() assert "original_run_arp_scan = run_arp_scan" in backend_scan_runtime_module assert "emit_to_client_override=wrapped_emit" in backend_scan_runtime_module - assert "if (data.job_type === 'scan') {" in scan_runtime_module - assert "syncScanJobVisualState(data);" in scan_runtime_module - assert "syncScanVisualStateFromFeedback(message);" in scan_runtime_module assert "socket.on('report_complete', function (data) {" in audit_log_module assert "socket.on('update_status', function (data) {" in audit_log_module assert "window.exportVisibleLogs = exportVisibleLogs;" in audit_log_module settings_tab_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() reports_tab_source = (ROOT / "static" / "js" / "reports_tab.js").read_text() customer_ui_source = (ROOT / "static" / "js" / "customer_ui.js").read_text() - assert "async function exportRuntimeDatabase()" in settings_tab_source - assert "fetch('/api/runtime/export')" in settings_tab_source - assert "window.exportRuntimeDatabase = exportRuntimeDatabase;" in settings_tab_source + assert "renderSettingsRuntimeSummary" in settings_tab_source + assert "'settings-runtime-export-btn'" in settings_tab_source assert "let reportsCustomerFilter = 'all';" in reports_tab_source assert "let historyViewMode = 'current';" in reports_tab_source assert "function renderReportsCustomerFilters(scans)" in reports_tab_source @@ -1483,10 +1526,8 @@ def test_frontend_modules_do_not_require_duplicate_globals_or_missing_init_deps( assert "function buildTimelineLabels(scans, latestPath)" in reports_tab_source assert "const container = document.getElementById('reports-customer-filters');" in reports_tab_source assert "reportsCustomerFilter = filterValue;" in reports_tab_source - assert "let customersTabLoaded = false;" in customer_ui_source - assert "function renderCustomersTab(customers)" in customer_ui_source - assert "function loadCustomersTab(force = false)" in customer_ui_source - assert "socket.emit(customerFormMode === 'edit' ? 'update_customer' : 'add_customer'" in customer_ui_source + assert "function renderCustomersTab(customers = loadLocalCustomers())" in customer_ui_source + assert "function loadCustomersTab()" in customer_ui_source assert "window.loadCustomersTab = loadCustomersTab;" in customer_ui_source @@ -1517,8 +1558,8 @@ def test_google_drive_integration_contract_exists(): assert "REMOTE_SYNC_SECRET_FILE" in app_source assert "function uploadReportToGoogleDrive(scanPath)" in reports_tab_source assert "Upload to Drive" in reports_tab_source - assert "async function connectGoogleDrive()" in settings_tab_source - assert "async function disconnectGoogleDriveAccount()" in settings_tab_source + assert "fetch('/api/settings/google-drive/auth-url'" in settings_tab_source + assert "fetch('/api/settings/google-drive/disconnect'" in settings_tab_source assert 'id="settings-google-drive-connect-btn"' in template assert 'id="settings-google-drive-disconnect-btn"' in template settings_source = (ROOT / "nmapui" / "settings.py").read_text() @@ -1588,7 +1629,7 @@ def test_template_does_not_keep_inline_report_generation_block(): assert '' in template assert '' in template assert "initializeAuditLog();" in template - assert "initializeSettingsTab();" in template + assert "typeof initializeSettingsTab === 'function'" in template settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() reports_source = (ROOT / "static" / "js" / "reports_tab.js").read_text() customer_ui_source = (ROOT / "static" / "js" / "customer_ui.js").read_text() @@ -1597,9 +1638,8 @@ def test_template_does_not_keep_inline_report_generation_block(): report_status_source = (ROOT / "static" / "js" / "report_status.js").read_text() runtime_history_source = (ROOT / "nmapui" / "runtime_history.py").read_text() reporting_source = (ROOT / "nmapui" / "reporting.py").read_text() - assert "settings-profile-scan-only-mode" in settings_source - assert "settings-profile-excluded-targets" in settings_source - assert "profile.scan_rules?.scan_only_mode" in settings_source + assert 'id="settings-profile-scan-only-mode"' in template + assert "scanOnlyMode: document.getElementById('settings-scan-only-mode')?.checked || false," in settings_source assert "function updateReportsBadge(scans)" in reports_source assert "document.getElementById('reports-badge')" in reports_source assert "loadReportsTab(true);" in reports_source @@ -1609,7 +1649,9 @@ def test_template_does_not_keep_inline_report_generation_block(): assert "Showing ${visibleScans.length} scan(s) for the current network context." in reports_source assert "loadCustomersTab()" in reports_source assert "id=\"cust-public-ip\"" in template - assert "socket.on('customer_updated'" in customer_ui_source + # customer updates fan out from the backend handler; UI refreshes via customer_profile. + assert "socket.on('customer_profile'" in (ROOT / "static" / "js" / "customer_actions.js").read_text() or \ + "renderCustomerFingerprint" in customer_ui_source assert "@socketio.on(\"update_customer\")" in (ROOT / "nmapui" / "handlers" / "customers.py").read_text() assert "removeReportProgressCard();" in report_status_source assert "resolve_report_customer_identity(" in runtime_history_source @@ -1676,24 +1718,13 @@ def test_template_uses_dom_helpers_for_scan_result_rendering(): assert "buildRuntimeReportArtifactUrl(scan.path, 'xml')" in reports_tab_module assert "Select Base" in reports_tab_module assert "Compare to Base" in reports_tab_module - assert "async function loadSettingsTab(force = false)" in settings_tab_module - assert "async function saveSettingsTab()" in settings_tab_module - assert "async function testGoogleDriveSettings()" in settings_tab_module - assert "async function testRemoteSyncSettings()" in settings_tab_module - assert "async function runRuntimeBackfill()" in settings_tab_module - assert "async function runRuntimeRetention()" in settings_tab_module + assert "function loadSettingsTab()" in settings_tab_module + assert "function saveSettingsTab()" in settings_tab_module + assert "socket.emit('get_google_drive_status');" in settings_tab_module + assert "document.getElementById('settings-remote-sync-test-btn')?.addEventListener" in settings_tab_module assert "function addTargetProfile()" in settings_tab_module - assert "function applyProfileToDashboard(profile)" in settings_tab_module - assert "setSyncStatus('settings-google-drive-status'" in settings_tab_module - assert "setSyncStatus('settings-remote-sync-status'" in settings_tab_module - assert "setMaintenanceStatus('Running runtime backfill...')" in settings_tab_module - assert "function syncMaintenanceStatusFromSummary(summary)" in settings_tab_module - assert "const lastBackfillValue =" in settings_tab_module - assert "const lastRetentionValue =" in settings_tab_module - assert "fetch('/api/settings/validate/google-drive'" in settings_tab_module - assert "fetch('/api/settings/validate/remote-sync'" in settings_tab_module - assert "fetch('/api/runtime/maintenance/backfill'" in settings_tab_module - assert "fetch('/api/runtime/maintenance/retention'" in settings_tab_module + assert "'settings-runtime-backfill-btn'" in settings_tab_module + assert "setGoogleDriveStatus('Checking Google Drive status...');" in settings_tab_module assert "window.initializeSettingsTab = initializeSettingsTab;" in settings_tab_module assert "window.loadSettingsTab = loadSettingsTab;" in settings_tab_module assert 'id="settings-runtime-backfill-btn"' in template