diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 7d9a535..f760ae1 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -12,7 +12,7 @@ from app.db.database import AsyncSessionLocal, get_db from app.db.models import Node, PendingDevice, ScanRun from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse -from app.services.scanner import run_scan +from app.services.scanner import request_cancel, run_scan class ScanConfig(BaseModel): @@ -43,6 +43,21 @@ async def trigger_scan( return run +@router.post("/{run_id}/stop", response_model=dict) +async def stop_scan( + run_id: str, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> dict[str, bool]: + run = await db.get(ScanRun, run_id) + if not run: + raise HTTPException(status_code=404, detail="Scan run not found") + if run.status != "running": + raise HTTPException(status_code=409, detail="Scan is not running") + request_cancel(run_id) + return {"stopping": True} + + @router.get("/pending", response_model=list[PendingDeviceResponse]) async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 70e236f..5899503 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -12,6 +12,19 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type logger = logging.getLogger(__name__) +# Run IDs that have been requested to cancel +_cancelled_runs: set[str] = set() + + +def request_cancel(run_id: str) -> None: + """Signal a running scan to stop early.""" + _cancelled_runs.add(run_id) + + +def _is_cancelled(run_id: str) -> bool: + return run_id in _cancelled_runs + + try: import nmap _NMAP_AVAILABLE = True @@ -123,10 +136,15 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: await db.commit() for cidr in ranges: + if _is_cancelled(run_id): + break + # Run nmap in a thread pool — does not block the event loop hosts = await asyncio.to_thread(_nmap_scan, cidr) for host in hosts: + if _is_cancelled(run_id): + break ip = host["ip"] # Skip if device is already in the canvas (approved node) @@ -190,10 +208,10 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: # Push WS event so the frontend refreshes pending panel await broadcast_scan_update(run_id=run_id, devices_found=devices_found) - # Mark scan as done + # Mark scan as done or cancelled run = await db.get(ScanRun, run_id) if run: - run.status = "done" + run.status = "cancelled" if _is_cancelled(run_id) else "done" run.devices_found = devices_found run.finished_at = datetime.now(timezone.utc) await db.commit() @@ -206,3 +224,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: run.error = str(exc) run.finished_at = datetime.now(timezone.utc) await db.commit() + finally: + _cancelled_runs.discard(run_id) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index c03b8b4..0810040 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -1,4 +1,4 @@ -"""Tests for scan routes: trigger, pending devices, approve/hide/ignore.""" +"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" import uuid from unittest.mock import AsyncMock, patch @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Node, PendingDevice, ScanRun -from app.services.scanner import run_scan +from app.services.scanner import _cancelled_runs, request_cancel, run_scan @pytest.fixture @@ -312,6 +312,99 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession): assert result.scalar_one_or_none() is None +# --- Stop scan --- + +@pytest.mark.asyncio +async def test_stop_scan_requires_auth(client: AsyncClient): + res = await client.post("/api/v1/scan/fake-id/stop") + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_stop_scan_not_found(client: AsyncClient, headers): + res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers) + assert res.status_code == 404 + + +@pytest.mark.asyncio +async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession): + run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"]) + db_session.add(run) + await db_session.commit() + + res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession): + run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"]) + db_session.add(run) + await db_session.commit() + + res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers) + assert res.status_code == 200 + assert res.json() == {"stopping": True} + # run_id added to cancel set + assert run.id in _cancelled_runs + # cleanup for other tests + _cancelled_runs.discard(run.id) + + +# --- run_scan cancellation --- + +@pytest.mark.asyncio +async def test_run_scan_cancelled_marks_status(db_session: AsyncSession): + """When cancel is requested before the scan starts, status becomes 'cancelled'.""" + run_id = str(uuid.uuid4()) + run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]) + db_session.add(run) + await db_session.commit() + + request_cancel(run_id) + + with ( + patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap, + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock), + ): + await run_scan(["192.168.1.0/24"], db_session, run_id) + # nmap should not have been called — cancelled before first range + mock_nmap.assert_not_called() + + await db_session.refresh(run) + assert run.status == "cancelled" + assert run.finished_at is not None + + +@pytest.mark.asyncio +async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession): + """Cancel flag set after first CIDR is started prevents processing of the second CIDR.""" + run_id = str(uuid.uuid4()) + run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"]) + db_session.add(run) + await db_session.commit() + + call_count = 0 + + def nmap_side_effect(target: str): + nonlocal call_count + call_count += 1 + # Signal cancellation after the first CIDR scan completes + if call_count == 1: + request_cancel(run_id) + return [] + + with ( + patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect), + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock), + ): + await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id) + + assert call_count == 1 # second CIDR was skipped + await db_session.refresh(run) + assert run.status == "cancelled" + + @pytest.mark.asyncio async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession): """Re-scanning the same IP updates services instead of creating a duplicate.""" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5d5f086..e1601b5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -59,6 +59,7 @@ export const scanApi = { approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), hide: (id: string) => api.post(`/scan/pending/${id}/hide`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), + stop: (runId: string) => api.post(`/scan/${runId}/stop`), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), } diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 14b2e24..b518352 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -387,8 +387,26 @@ function ScanHistoryPanel() { return () => clearInterval(id) }, [runs, load]) + const [stopping, setStopping] = useState(null) + + const handleStop = async (runId: string) => { + setStopping(runId) + try { + await scanApi.stop(runId) + toast.success('Scan stop requested') + } catch { + toast.error('Failed to stop scan') + } finally { + setStopping(null) + } + } + const statusColor = (s: string) => - s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e' + s === 'done' ? '#39d353' + : s === 'running' ? '#e3b341' + : s === 'error' ? '#f85149' + : s === 'cancelled' ? '#8b949e' + : '#8b949e' return (
@@ -409,6 +427,24 @@ function ScanHistoryPanel() { {r.status} {r.status === 'running' && } {r.devices_found} found + {r.status === 'running' && ( + + + + + Stop scan + + )}
{new Date(r.started_at).toLocaleString()} diff --git a/frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx b/frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx new file mode 100644 index 0000000..3f3d282 --- /dev/null +++ b/frontend/src/components/panels/__tests__/ScanHistoryPanel.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Sidebar } from '../Sidebar' +import * as canvasStore from '@/stores/canvasStore' +import { TooltipProvider } from '@/components/ui/tooltip' + +vi.mock('@/stores/canvasStore') +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/api/client', () => ({ + scanApi: { + trigger: vi.fn(), + pending: vi.fn().mockResolvedValue({ data: [] }), + hidden: vi.fn().mockResolvedValue({ data: [] }), + runs: vi.fn().mockResolvedValue({ data: [] }), + stop: vi.fn(), + getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), + }, + settingsApi: { get: vi.fn(), save: vi.fn() }, +})) + +import { scanApi } from '@/api/client' +import { toast } from 'sonner' + +const RUNNING_RUN = { + id: 'run-1', + status: 'running', + ranges: ['192.168.1.0/24'], + devices_found: 2, + started_at: new Date().toISOString(), + finished_at: null, + error: null, +} + +const DONE_RUN = { + id: 'run-2', + status: 'done', + ranges: ['192.168.1.0/24'], + devices_found: 3, + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + error: null, +} + +const CANCELLED_RUN = { + id: 'run-3', + status: 'cancelled', + ranges: ['192.168.1.0/24'], + devices_found: 1, + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + error: null, +} + +function renderSidebar() { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [], + hasUnsavedChanges: false, + hideIp: false, + toggleHideIp: vi.fn(), + addNode: vi.fn(), + scanEventTs: 0, + } as unknown as ReturnType) + + return render( + + + + ) +} + +async function openHistory() { + fireEvent.click(screen.getByRole('button', { name: 'Scan History' })) + // Wait for runs to load + await waitFor(() => expect(scanApi.runs).toHaveBeenCalled()) +} + +describe('ScanHistoryPanel — stop scan', () => { + beforeEach(() => { + vi.mocked(toast.success).mockReset() + vi.mocked(toast.error).mockReset() + vi.mocked(scanApi.stop).mockReset() + vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never) + }) + + it('shows stop button only for running scans', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) + renderSidebar() + await openHistory() + + await waitFor(() => expect(screen.getByText('running')).toBeDefined()) + + // Exactly one stop button rendered (for the running scan only) + const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' }) + expect(stopButtons).toHaveLength(1) + }) + + it('calls scanApi.stop with the correct run ID on click', async () => { + vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) + renderSidebar() + await openHistory() + + const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) + fireEvent.click(stopBtn) + + await waitFor(() => { + expect(scanApi.stop).toHaveBeenCalledWith('run-1') + }) + }) + + it('shows success toast when stop succeeds', async () => { + vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) + renderSidebar() + await openHistory() + + const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) + fireEvent.click(stopBtn) + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Scan stop requested') + }) + }) + + it('shows error toast when stop fails', async () => { + vi.mocked(scanApi.stop).mockRejectedValue(new Error('network')) + vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) + renderSidebar() + await openHistory() + + const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) + fireEvent.click(stopBtn) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to stop scan') + }) + }) + + it('renders cancelled status without stop button or spinner', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never) + renderSidebar() + await openHistory() + + await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined()) + + // No stop button + expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull() + }) +})