Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,41 @@ def _read_version() -> str:
_VERSION = _read_version()


def _mark_interrupted_scans() -> None:
"""Mark any scan still in 'running' status as failed.

Scans left in 'running' after a container restart were killed mid-flight
and will never complete. Leaving them in that state causes the frontend to
poll /api/scans indefinitely. Errors are caught so a stale or pre-migration
database never prevents the server from starting.
"""
from datetime import UTC, datetime

from app.db import SessionLocal
from app.models.scan import Scan

db = SessionLocal()
try:
stale = db.query(Scan).filter(Scan.status == "running").all()
if stale:
now = datetime.now(tz=UTC)
for scan in stale:
scan.status = "failed"
scan.finished_at = now
scan.error_message = "Scan interrupted by server restart"
scan.current_stage = None
db.commit()
logger.info("Marked %d interrupted scan(s) as failed on startup", len(stale))
except Exception: # noqa: BLE001 — startup cleanup; never prevent the server from starting
logger.warning("Could not clean up interrupted scans on startup (schema mismatch?)")
finally:
db.close()


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
init_db()
_mark_interrupted_scans()

from apscheduler.schedulers.background import BackgroundScheduler

Expand Down
29 changes: 20 additions & 9 deletions frontend/src/hooks/useScanStatus.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* useScanStatus — polls GET /api/scans every `intervalMs` ms while a scan
* is running (status === 'running'). Returns the latest scan and whether
* a scan is currently in progress.
* useScanStatus — fetches GET /api/scans on mount, then fast-polls every
* `intervalMs` ms ONLY while a scan is running (status === 'running').
* When no scan is running it falls back to a slow refresh every 30 s so
* the UI stays eventually-consistent without hammering the API.
*/
import { useEffect, useRef, useState } from "react";
import type { Scan } from "../types/api";
Expand All @@ -13,11 +14,13 @@ export interface UseScanStatusResult {
}

const RUNNING_STATUS = new Set(["running", "pending", "in_progress"]);
const IDLE_INTERVAL_MS = 30_000;

export function useScanStatus(intervalMs = 3000): UseScanStatusResult {
const [latestScan, setLatestScan] = useState<Scan | null>(null);
const [loading, setLoading] = useState(true);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isRunningRef = useRef(false);

const fetchScans = (silent = false) => {
if (!silent) setLoading(true);
Expand All @@ -29,6 +32,17 @@ export function useScanStatus(intervalMs = 3000): UseScanStatusResult {
const latest = data[0] ?? null;
setLatestScan(latest);
setLoading(false);

const nowRunning = latest !== null && RUNNING_STATUS.has(latest.status);
// Reschedule interval only when running-state changes
if (nowRunning !== isRunningRef.current) {
isRunningRef.current = nowRunning;
if (intervalRef.current !== null) clearInterval(intervalRef.current);
intervalRef.current = setInterval(
() => fetchScans(true),
nowRunning ? intervalMs : IDLE_INTERVAL_MS,
);
}
})
.catch(() => {
setLoading(false);
Expand All @@ -37,16 +51,13 @@ export function useScanStatus(intervalMs = 3000): UseScanStatusResult {

useEffect(() => {
fetchScans();

intervalRef.current = setInterval(() => {
// Always poll; only matters visually when running
fetchScans(true);
}, intervalMs);
// Start with idle interval; fetchScans will upgrade to fast-poll if running
intervalRef.current = setInterval(() => fetchScans(true), IDLE_INTERVAL_MS);

return () => {
if (intervalRef.current !== null) clearInterval(intervalRef.current);
};
}, [intervalMs]);
}, [intervalMs]); // eslint-disable-line react-hooks/exhaustive-deps -- fetchScans is stable

const isRunning =
latestScan !== null && RUNNING_STATUS.has(latestScan.status);
Expand Down
Loading