From 0291015beace7ae703e617634916ec4936ada5d8 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 12:54:00 -0400 Subject: [PATCH 1/7] test: align runtime contract tests with current module split; real gaps fixed - .gitignore: track-ignore config/auto_scan_config.json + .playwright-browsers/ - site_chrome.js: export initializeSiteChrome on window - README: document NMAPUI_SWIFT_TARGET / NMAPUI_APPLICATIONS_DIR / NMAPUI_MIGRATE_DB build vars and scripts/backfill_runtime_store.py - contract tests: update stale assertions to the slimmer JS modules from PR #231 (settings_tab, customer_ui/actions, auto_scan_ui, scan_runtime, audit_log, reports_tab) so CI reflects actual behavior --- .gitignore | 3 +- README.md | 17 +++++ static/js/site_chrome.js | 2 + tests/test_runtime_contract.py | 109 ++++++++++++--------------------- 4 files changed, 59 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index 61247514..e355d740 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/ +config/auto_scan_config.json \ No newline at end of file diff --git a/README.md b/README.md index ef253cb9..2aeda259 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/site_chrome.js b/static/js/site_chrome.js index 763cd6ec..3a249bab 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 6a43253d..79cadb1d 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,7 +131,7 @@ 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 @@ -149,11 +145,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 "socket.emit('connect_google_drive');" in settings_source + assert "socket.emit('disconnect_google_drive');" in settings_source + assert "socket.emit('save_google_drive_credentials', { credentialsJson: String(reader.result || '') });" 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 +563,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 +746,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 +1363,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 +1396,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 +1432,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 +1445,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 +1462,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 +1494,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 "socket.emit('connect_google_drive');" in settings_tab_source + assert "socket.emit('disconnect_google_drive');" 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 +1565,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 +1574,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 +1585,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 +1654,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 From bf5dba26986cc90e930e1b621cfac739ac37d13d Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 13:22:03 -0400 Subject: [PATCH 2/7] fix: wire settings/auto-scan UI to the Flask runtime contract (Codex review) Addresses all four findings from the Codex bot review on PR #239. The frontend still emitted Node-dev-runtime socket events that the packaged Flask runtime never registered, leaving controls dead in production. - auto_scan_ui.js: enable/disable now POSTs /api/auto_scan/update and listens for auto_scan_status; socket emits kept as Node fallback - settings_tab.js Google Drive buttons: use /api/settings/google-drive/* HTTP routes (status, auth-url, disconnect, credentials) instead of unhandled socket events - settings_tab.js save: persists scan_rules/reports/sync via POST /api/settings so scan-only mode, excluded targets, and max scan minutes actually reach get_effective_scan_rules; localStorage kept as cache - maintenance buttons: call /api/runtime/maintenance/backfill, /api/runtime/maintenance/retention, and /api/runtime/export instead of showing 'not connected in this build' - contract tests: new tests pin each of these runtime contracts --- static/js/auto_scan_ui.js | 79 ++++++++++---- static/js/settings_tab.js | 192 ++++++++++++++++++++++++++++----- tests/test_runtime_contract.py | 58 +++++++++- 3 files changed, 280 insertions(+), 49 deletions(-) diff --git a/static/js/auto_scan_ui.js b/static/js/auto_scan_ui.js index 8215e904..83d2d59f 100644 --- a/static/js/auto_scan_ui.js +++ b/static/js/auto_scan_ui.js @@ -1,38 +1,81 @@ -function initializeAutoScanUI(socket) { +let autoScanHttpBusy = false; + +function applyAutoScanConfigState(config = {}) { + if (!config || typeof config !== 'object') return; + // Flask payloads use start_time; the Node dev runtime uses startTime. + window.currentAutoScanConfig = config || {}; 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) autoScanToggle.checked = !!config.enabled; + if (recurrenceInput) recurrenceInput.value = config.recurrence || 'daily'; + if (startTimeInput) startTimeInput.value = config.startTime || config.start_time || '01: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) return true; + 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; + applyAutoScanConfigState(await response.json()); + 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'); 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 recurrence = document.getElementById('auto-recurrence')?.value || 'daily'; + const startTime = document.getElementById('auto-start-time')?.value || '01:00'; + const target = document.getElementById('scan-target')?.value; - window.socket.emit('enable_auto_scan', { recurrence, startTime, target }); + const handled = await sendAutoScanUpdate({ enabled: true, start_time: startTime }); + if (!handled) { + window.socket.emit('enable_auto_scan', { recurrence, startTime, target }); + } document.getElementById('auto-scan-modal').classList.add('hidden'); } diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 62fe95c1..4005b592 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -115,30 +115,107 @@ 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 || '', + }); + renderGoogleDriveSummary({ config: sync.google_drive || {}, status: sync.google_drive_status || {} }); +} + +async function fetchServerSettings() { try { - const saved = JSON.parse(localStorage.getItem(getSettingsStorageKey()) || 'null'); - applySettingsForm(saved); - 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.'); + const response = await fetch('/api/settings'); + if (!response.ok) return null; + return await response.json(); } catch (error) { - setSettingsStatus('Failed to load local settings.', true); + return null; } } -function saveSettingsTab() { +async function loadSettingsTab() { + let saved = null; try { - const settings = collectSettingsForm(); + saved = JSON.parse(localStorage.getItem(getSettingsStorageKey()) || 'null'); + } catch (error) { + 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.'); + } +} + +function buildServerSettingsPayload(settings) { + 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 || '', + }, + }, + }; +} + +async function saveSettingsTab() { + let settings; + try { + 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 { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildServerSettingsPayload(settings)), }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success !== true) { + throw new Error(payload.error || `Save failed (${response.status})`); + } 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}).`); } } @@ -198,17 +275,39 @@ 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'); + 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) { + setGoogleDriveStatus(`Unable to start Google Drive authorization: ${error.message}`, true); + } }); - 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' }); + const result = await response.json(); + if (!result.success) throw new Error(result.error || 'Disconnect failed'); + setGoogleDriveStatus(result.status || 'Google Drive disconnected.'); + } catch (error) { + setGoogleDriveStatus(`Disconnect failed: ${error.message}`, true); + } }); document.getElementById('settings-google-drive-import-btn')?.addEventListener('click', () => { document.getElementById('settings-google-drive-credentials-file')?.click(); @@ -217,9 +316,20 @@ 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 || '') }), + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error || 'Import rejected'); + setGoogleDriveStatus(result.status || 'Credentials imported.'); + } catch (error) { + setGoogleDriveStatus(`Credential import failed: ${error.message}`, true); + } }; reader.onerror = () => setGoogleDriveStatus('Failed to read credentials file.', true); reader.readAsText(file); @@ -227,10 +337,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/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 79cadb1d..ccbf168f 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -136,6 +136,54 @@ def test_settings_tab_includes_auto_monitor_defaults(): 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.""" + 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 "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.""" + 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 + 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 emitted. + for dead_event in ("'connect_google_drive'", "'disconnect_google_drive'", "'save_google_drive_credentials'"): + assert f"emit({dead_event}" not in settings_source + + +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() @@ -145,9 +193,9 @@ 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 "socket.emit('connect_google_drive');" in settings_source - assert "socket.emit('disconnect_google_drive');" in settings_source - assert "socket.emit('save_google_drive_credentials', { credentialsJson: String(reader.result || '') });" 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 @@ -1494,8 +1542,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 "socket.emit('connect_google_drive');" in settings_tab_source - assert "socket.emit('disconnect_google_drive');" 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() From 3bd40afbacf0941f572d304889f61c054561da66 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 13:43:21 -0400 Subject: [PATCH 3/7] fix: address second Codex review round on settings/auto-scan wiring - saveSettingsTab: POST /api/settings replaces the whole document server-side, so fetch the current doc first and carry forward target_profiles, auto_monitor, and remote_sync api_key_configured - auto_scan_ui: /api/auto_scan/update ack is only {success:true}; apply the effective config via a follow-up GET /api/auto_scan/status - Drive actions: keep Node-runtime socket events as fallback when the Flask HTTP routes are unavailable (npm start dev path) --- static/js/auto_scan_ui.js | 4 +++- static/js/settings_tab.js | 27 ++++++++++++++++++++++----- tests/test_runtime_contract.py | 10 +++++++--- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/static/js/auto_scan_ui.js b/static/js/auto_scan_ui.js index 83d2d59f..898306fc 100644 --- a/static/js/auto_scan_ui.js +++ b/static/js/auto_scan_ui.js @@ -24,7 +24,9 @@ async function sendAutoScanUpdate(payload) { body: JSON.stringify(payload), }); if (!response.ok) return false; - applyAutoScanConfigState(await response.json()); + // 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; diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 4005b592..0c2e462d 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -162,7 +162,11 @@ async function loadSettingsTab() { } } -function buildServerSettingsPayload(settings) { +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, @@ -183,8 +187,13 @@ function buildServerSettingsPayload(settings) { remote_sync: { enabled: !!settings.remoteSyncEnabled, endpoint: settings.remoteSyncEndpoint || '', + api_key_configured: !!(existingSync.remote_sync || {}).api_key_configured, }, }, + target_profiles: Array.isArray(existingDoc.target_profiles) + ? existingDoc.target_profiles + : [], + auto_monitor: existingDoc.auto_monitor || undefined, }; } @@ -202,10 +211,11 @@ async function saveSettingsTab() { folderId: settings.googleDriveFolder }); try { + const currentDoc = await fetchServerSettings(); const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildServerSettingsPayload(settings)), + body: JSON.stringify(buildServerSettingsPayload(settings, currentDoc || {})), }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.success !== true) { @@ -290,23 +300,28 @@ function initializeSettingsTab(socket) { setGoogleDriveStatus('Starting Google Drive authorization...'); 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) { - setGoogleDriveStatus(`Unable to start Google Drive authorization: ${error.message}`, true); + // 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', async () => { setGoogleDriveStatus('Disconnecting 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.'); } catch (error) { - setGoogleDriveStatus(`Disconnect failed: ${error.message}`, true); + // Node dev runtime fallback. + socket.emit('disconnect_google_drive'); } }); document.getElementById('settings-google-drive-import-btn')?.addEventListener('click', () => { @@ -324,11 +339,13 @@ function initializeSettingsTab(socket) { 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) { - setGoogleDriveStatus(`Credential import failed: ${error.message}`, true); + // Node dev runtime fallback. + socket.emit('save_google_drive_credentials', { credentialsJson: String(reader.result || '') }); } }; reader.onerror = () => setGoogleDriveStatus('Failed to read credentials file.', true); diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index ccbf168f..03e6b225 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -155,6 +155,10 @@ def test_settings_tab_persists_scan_rules_to_server(): 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 target_profiles and auto_monitor. + assert "target_profiles: Array.isArray(existingDoc.target_profiles)" in settings_source + assert "auto_monitor: existingDoc.auto_monitor" in settings_source workflows_source = (ROOT / "nmapui" / "workflows.py").read_text() assert "get_effective_scan_rules(" in workflows_source @@ -170,9 +174,9 @@ def test_settings_tab_google_drive_actions_use_http_routes(): "/api/settings/google-drive/credentials", ): assert f"fetch('{route}'" in settings_source - # The dead socket events must no longer be emitted. - for dead_event in ("'connect_google_drive'", "'disconnect_google_drive'", "'save_google_drive_credentials'"): - assert f"emit({dead_event}" not 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(): From 03ba4894b15fd6080876328b1bd3a722fc408beb Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 13:56:28 -0400 Subject: [PATCH 4/7] fix: third Codex review round - auto-scan window, auto-monitor defaults, gitignore path - auto_scan_ui: send start_time AND end_time to /api/auto_scan/update (the modal is a daily window; recurrence/target are Node-only concepts and the template has no such inputs); apply end_time on load too - settings_tab: POST the edited auto-monitor defaults (enabled_by_default, recurrence, day_of_week, time) instead of echoing existingDoc back; keep existing rules; server defaults now hydrate the form on load - .gitignore: Flask writes auto_scan_config.json at BASE_DIR (repo root), not config/; bare pattern keeps the tracked example file intact --- .gitignore | 2 +- static/js/auto_scan_ui.js | 15 ++++++++------- static/js/settings_tab.js | 31 ++++++++++++++++++++++++++++++- tests/test_runtime_contract.py | 14 ++++++++++---- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index e355d740..dbb7a6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,4 @@ work/ .venv/ .claude/settings.local.json .playwright-browsers/ -config/auto_scan_config.json \ No newline at end of file +auto_scan_config.json \ No newline at end of file diff --git a/static/js/auto_scan_ui.js b/static/js/auto_scan_ui.js index 898306fc..85d141c3 100644 --- a/static/js/auto_scan_ui.js +++ b/static/js/auto_scan_ui.js @@ -2,14 +2,14 @@ let autoScanHttpBusy = false; function applyAutoScanConfigState(config = {}) { if (!config || typeof config !== 'object') return; - // Flask payloads use start_time; the Node dev runtime uses startTime. + // Flask payloads use start_time/end_time; the Node dev runtime uses startTime. window.currentAutoScanConfig = config || {}; const autoScanToggle = document.getElementById('auto-scan-toggle'); - const recurrenceInput = document.getElementById('auto-recurrence'); const startTimeInput = document.getElementById('auto-start-time'); + const endTimeInput = document.getElementById('auto-end-time'); if (autoScanToggle) autoScanToggle.checked = !!config.enabled; - if (recurrenceInput) recurrenceInput.value = config.recurrence || 'daily'; 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 @@ -70,13 +70,14 @@ function initializeAutoScanUI(socket) { } async function saveAutoScanTimes() { - const recurrence = document.getElementById('auto-recurrence')?.value || 'daily'; const startTime = document.getElementById('auto-start-time')?.value || '01:00'; - const target = document.getElementById('scan-target')?.value; + const endTime = document.getElementById('auto-end-time')?.value || '06:00'; - const handled = await sendAutoScanUpdate({ enabled: true, start_time: startTime }); + // 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', { recurrence, startTime, target }); + 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 0c2e462d..f83b2d42 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -129,6 +129,20 @@ function applyServerSettingsDocument(doc = {}) { 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: sync.google_drive_status || {} }); } @@ -162,6 +176,21 @@ async function loadSettingsTab() { } } +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 @@ -193,7 +222,7 @@ function buildServerSettingsPayload(settings, existingDoc = {}) { target_profiles: Array.isArray(existingDoc.target_profiles) ? existingDoc.target_profiles : [], - auto_monitor: existingDoc.auto_monitor || undefined, + auto_monitor: buildAutoMonitorDefaultsPayload(settings, existingDoc), }; } diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 03e6b225..59b8282a 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -138,10 +138,12 @@ def test_settings_tab_includes_auto_monitor_defaults(): 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.""" + 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 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 @@ -149,16 +151,20 @@ def test_auto_scan_ui_uses_flask_runtime_contract(): 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.""" + 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 target_profiles and auto_monitor. + # payload must carry forward sections outside the form. assert "target_profiles: Array.isArray(existingDoc.target_profiles)" in settings_source - assert "auto_monitor: existingDoc.auto_monitor" 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 From 5cab8eb82193acb177ea949347f8a302036f1ee1 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 14:10:40 -0400 Subject: [PATCH 5/7] fix: fourth Codex round - Drive summary status + safe replace-save - loadSettingsTab: fetch real connection state from /api/settings/google-drive/status instead of rendering the settings doc alone (which lacks connected/status and showed 'Credentials missing') - saveSettingsTab: abort a replacement POST when the pre-read of the current settings document fails - /api/settings clears and replaces server state, so saving blind could wipe target_profiles and auto-monitor rules --- static/js/settings_tab.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index f83b2d42..644037ac 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -143,7 +143,26 @@ function applyServerSettingsDocument(doc = {}) { if (document.getElementById('settings-auto-monitor-time')) { document.getElementById('settings-auto-monitor-time').value = defaults.time || '01:00'; } - renderGoogleDriveSummary({ config: sync.google_drive || {}, status: sync.google_drive_status || {} }); + renderGoogleDriveSummary({ config: sync.google_drive || {}, status: {} }); +} + +async function refreshGoogleDriveSummaryFromStatus() { + try { + 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'); + } } async function fetchServerSettings() { @@ -174,6 +193,7 @@ async function loadSettingsTab() { } else { setSettingsStatus(saved ? 'Settings loaded from this browser.' : 'Settings tab ready.'); } + await refreshGoogleDriveSummaryFromStatus(); } function buildAutoMonitorDefaultsPayload(settings, existingDoc = {}) { @@ -240,11 +260,17 @@ async function saveSettingsTab() { 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 || {})), + body: JSON.stringify(buildServerSettingsPayload(settings, currentDoc)), }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.success !== true) { From 5b4d88f005d20a1e7f2d4434ebcbf1242a4f6003 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 14:17:47 -0400 Subject: [PATCH 6/7] fix: fifth Codex round - persist new target profiles, disable Drive sync on disconnect - addTargetProfile now builds a normalized profile object tracked in pendingTargetProfiles; saveSettingsTab merges them into the POST payload (previously profiles were DOM-only and lost on refresh) and clears the pending list after a successful save - Drive disconnect now also flips sync.google_drive.enabled off via /api/settings - the Flask disconnect route only revokes the token, so report uploads kept failing until the user manually disabled sync --- static/js/settings_tab.js | 47 +++++++++++++++++++++++++++++++--- tests/test_runtime_contract.py | 6 +++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 644037ac..2d99b5d2 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -239,9 +239,10 @@ function buildServerSettingsPayload(settings, existingDoc = {}) { api_key_configured: !!(existingSync.remote_sync || {}).api_key_configured, }, }, - target_profiles: Array.isArray(existingDoc.target_profiles) - ? existingDoc.target_profiles - : [], + target_profiles: [ + ...(Array.isArray(existingDoc.target_profiles) ? existingDoc.target_profiles : []), + ...pendingTargetProfiles, + ], auto_monitor: buildAutoMonitorDefaultsPayload(settings, existingDoc), }; } @@ -276,6 +277,7 @@ async function saveSettingsTab() { if (!response.ok || payload.success !== true) { throw new Error(payload.error || `Save failed (${response.status})`); } + pendingTargetProfiles = []; renderSettingsRuntimeSummary(); setSettingsStatus('Settings saved to the local runtime and this browser.'); } catch (error) { @@ -291,6 +293,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 || ''; @@ -300,11 +305,25 @@ 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: '', + customer_name: '', + notes, + scan_rules: { + scan_only_mode: false, + excluded_targets: [], + max_scan_minutes: 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) { @@ -374,6 +393,26 @@ function initializeSettingsTab(socket) { 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'); diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 59b8282a..56b40d83 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -160,8 +160,10 @@ def test_settings_tab_persists_scan_rules_to_server(): 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. - assert "target_profiles: Array.isArray(existingDoc.target_profiles)" in settings_source + # 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 From 8e722e57c4d9fdfb8f12db9403bd9ac4ebbf601a Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 24 Aug 2026 14:26:19 -0400 Subject: [PATCH 7/7] fix: sixth Codex round - profile scan rules, remote-sync api key, toggle race - addTargetProfile: serialize the profile-specific controls (scan-only override, excluded targets, max duration, customer) instead of hard-coding defaults - get_effective_scan_rules treats profiles as authoritative per target - settings save: include the entered remote-sync api_key so save_settings_state persists it to the encrypted secret store instead of leaving it browser-local - auto_scan_ui: an in-flight update now returns false rather than true so rapid toggle changes still emit over socket and are never silently dropped --- static/js/auto_scan_ui.js | 6 +++++- static/js/settings_tab.js | 14 ++++++++++---- tests/test_runtime_contract.py | 4 ++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/static/js/auto_scan_ui.js b/static/js/auto_scan_ui.js index 85d141c3..25c2dcd3 100644 --- a/static/js/auto_scan_ui.js +++ b/static/js/auto_scan_ui.js @@ -15,7 +15,11 @@ function applyAutoScanConfigState(config = {}) { // 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) return true; + 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', { diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 2d99b5d2..8954ed37 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -236,6 +236,7 @@ function buildServerSettingsPayload(settings, existingDoc = {}) { remote_sync: { enabled: !!settings.remoteSyncEnabled, endpoint: settings.remoteSyncEndpoint || '', + api_key: settings.remoteSyncApiKey || '', api_key_configured: !!(existingSync.remote_sync || {}).api_key_configured, }, }, @@ -309,13 +310,18 @@ function addTargetProfile() { id: (crypto?.randomUUID ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) : `p${Date.now()}`), name, target, - customer_id: '', + customer_id: document.getElementById('settings-profile-customer')?.value || '', customer_name: '', notes, scan_rules: { - scan_only_mode: false, - excluded_targets: [], - max_scan_minutes: 120, + 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); diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 56b40d83..b4ed39f1 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -144,6 +144,10 @@ def test_auto_scan_ui_uses_flask_runtime_contract(): 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