diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f51906..441f30c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Fixed + +- **Blank results grid after a browser refresh (especially after a server restart)** — restoring the last scan session on page load was one-shot: `_sseWatchdog()` called `loadHistorySession(null)` a single time, guarded by `_initialStatusChecked`. If that attempt was blocked — a completed scan's replayed `scan_phase` event leaves a `_*ScanRunning` flag set, and the `loadHistorySession` guard then bails — nothing retried, because `sse_replay_done` (the other retry path) only fires when the SSE replay buffer is non-empty, and the buffer is empty after a server restart (so refreshing after the in-app self-update reliably showed an empty grid even though the results were in the database). The watchdog now re-attempts the restore on every 4-second poll while nothing is shown and no scan is running, clearing stale running flags first (both scan locks are confirmed free at that point). Additionally, `/api/scan/status` now reports `google_running` separately from `running` (which only ever reflected the M365 + file lock), so a refresh during a live Google scan is detected instead of being treated as idle. + --- ## [1.7.7] — 2026-06-15 diff --git a/routes/scan.py b/routes/scan.py index dc7c791..30c8b7d 100644 --- a/routes/scan.py +++ b/routes/scan.py @@ -76,9 +76,13 @@ def scan_status(): acquired = state._scan_lock.acquire(blocking=False) if acquired: state._scan_lock.release() + g_acquired = state._google_scan_lock.acquire(blocking=False) + if g_acquired: + state._google_scan_lock.release() return jsonify({ - "running": not acquired, - "scan_id": _sse_mod._current_scan_id or None, + "running": not acquired, # M365 + file scan lock + "google_running": not g_acquired, # Google scan lock (separate) + "scan_id": _sse_mod._current_scan_id or None, }) diff --git a/static/js/CLAUDE.md b/static/js/CLAUDE.md index 2a84ba5..68d85ad 100644 --- a/static/js/CLAUDE.md +++ b/static/js/CLAUDE.md @@ -41,7 +41,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos ## Scan history browser — history.js + results.js - **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session. Set by `loadHistorySession()`; cleared by `exitHistoryMode()`. -- **Auto-load on page load** — `results.js` calls `window.loadHistorySession?.(null)` once when the SSE watchdog confirms `!status.running`. `_initialStatusChecked` guard ensures this fires at most once per page load. The `sse_replay_done` handler in `scan.js` retries if `loadHistorySession` bailed due to stale running flags set during replay. +- **Auto-load on page load** — `_sseWatchdog()` in `results.js` calls `window.loadHistorySession?.(null)` whenever `/api/scan/status` reports neither `running` (M365 + file lock) nor `google_running` (Google lock) **and** nothing is shown yet (`!S._historyRefScanId && !S.flaggedData.length`). This is **not one-shot** — it retries on every 4s poll until a session is restored, because (a) the replay buffer is empty after a server restart so `sse_replay_done` never fires, and (b) a completed scan's replayed `scan_phase` can leave a running flag set that would otherwise block the load forever. Because both locks are confirmed free, the watchdog clears the stale `_m365/_google/_fileScanRunning` flags before calling. Do not revert to a one-shot `_initialStatusChecked` gate — that reintroduces the "blank grid after refresh/restart" bug. `/api/scan/status` **must** report `google_running` separately; `running` alone misses live Google scans. The `sse_replay_done` handler in `scan.js` still retries for the non-empty-buffer (no-restart) case. - **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Do not hide/show from outside `history.js`. - **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` so the outside-click handler works correctly. Do not move the picker outside this wrapper. - **Cache invalidation** — `invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`. diff --git a/static/js/results.js b/static/js/results.js index ba38676..9b988c7 100644 --- a/static/js/results.js +++ b/static/js/results.js @@ -736,25 +736,34 @@ function _ensureSSE() { function _sseWatchdog() { fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) { - if (status.running) { + var anyRunning = status.running || status.google_running; + if (anyRunning) { // A scan is in progress — make sure SSE is connected and progress UI is visible _ensureSSE(); - if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) { + if (status.running && !S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) { document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; - // /api/scan/status checks the M365 lock — if running=true it's an M365 scan + // status.running reflects the M365 + file lock; treat as an M365 reconnect S._m365ScanRunning = true; _renderProgressSegments(); document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…'); log(t('m365_sse_reconnecting', 'Reconnecting to running scan…')); } + } else if (!S._historyRefScanId && !(S.flaggedData && S.flaggedData.length)) { + // No scan of any kind is running (authoritative, both locks free) and + // nothing is shown yet — restore the last saved session from the DB. + // Retried on every poll, not one-shot: the initial attempt can be blocked + // by running flags that SSE replay of a *completed* scan set but never + // cleared, and sse_replay_done only fires for a non-empty buffer (so it + // never retries after a server restart clears the replay buffer). + // Both locks are confirmed free, so clear any stale flags first. + S._m365ScanRunning = false; + S._googleScanRunning = false; + S._fileScanRunning = false; + window.loadHistorySession?.(null); } - if (!_initialStatusChecked) { - _initialStatusChecked = true; - if (!status.running) window.loadHistorySession?.(null); - } - // When no scan is running, we still keep polling — the SSE connection - // may have died and we need to detect the *next* scheduled scan. - // The SSE itself is only opened/reopened when a scan is detected. + _initialStatusChecked = true; + // Keep polling even when idle — the SSE connection may have died and we + // need to detect the next scheduled scan (SSE is only opened on demand). }).catch(function(err) { // Status endpoint unavailable — server might be restarting console.warn('[SSE] status poll failed:', err); diff --git a/tests/test_routes.py b/tests/test_routes.py index d909652..bd540d2 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -97,6 +97,22 @@ class TestScanStatus: assert "scan_id" in data assert data["scan_id"] is None + def test_idle_reports_google_not_running(self, client): + # The refresh/restore path relies on google_running being reported + # separately — running alone misses live Google scans. + data = client.get("/api/scan/status").get_json() + assert data["google_running"] is False + + def test_google_lock_held_reports_google_running(self, client): + from routes import state + assert state._google_scan_lock.acquire(blocking=False) + try: + data = client.get("/api/scan/status").get_json() + assert data["google_running"] is True + assert data["running"] is False # M365/file lock still free + finally: + state._google_scan_lock.release() + # --------------------------------------------------------------------------- # /api/scan/start