From ce4af14ee6affe5f36211a05669151822207a4d3 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 30 Mar 2026 22:01:47 +0200 Subject: [PATCH 1/5] feat: deduplicate pending devices and skip canvas/hidden nodes on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - At scan start, purge any pending entries whose IPs already exist in canvas - Skip canvas nodes (approved) during scan — don't re-add to pending - Skip hidden devices during scan — respect user's hide decision - Add 4 tests covering all new behaviors --- backend/app/services/scanner.py | 42 +++++++++++- backend/tests/test_scan.py | 115 +++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 85a27ea..70e236f 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -7,7 +7,7 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import PendingDevice, ScanRun +from app.db.models import Node, PendingDevice, ScanRun from app.services.fingerprint import fingerprint_ports, suggest_node_type logger = logging.getLogger(__name__) @@ -107,18 +107,54 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: devices_found = 0 try: + # Clean up stale pending devices whose IPs are already in the canvas + # (covers devices approved between scans, or pre-existing canvas nodes) + canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None))) + canvas_ips = {row[0] for row in canvas_ips_result.fetchall()} + if canvas_ips: + stale_result = await db.execute( + select(PendingDevice).where( + PendingDevice.status == "pending", + PendingDevice.ip.in_(canvas_ips), + ) + ) + for stale in stale_result.scalars().all(): + await db.delete(stale) + await db.commit() + for cidr in ranges: # Run nmap in a thread pool — does not block the event loop hosts = await asyncio.to_thread(_nmap_scan, cidr) for host in hosts: + ip = host["ip"] + + # Skip if device is already in the canvas (approved node) + canvas_result = await db.execute( + select(Node).where(Node.ip == ip) + ) + if canvas_result.scalar_one_or_none() is not None: + logger.debug("Skipping %s — already in canvas", ip) + continue + + # Skip if device was explicitly hidden by the user + hidden_result = await db.execute( + select(PendingDevice).where( + PendingDevice.ip == ip, + PendingDevice.status == "hidden", + ) + ) + if hidden_result.scalar_one_or_none() is not None: + logger.debug("Skipping %s — hidden by user", ip) + continue + services = fingerprint_ports(host["open_ports"]) suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) # Update existing pending device or create a new one existing_result = await db.execute( select(PendingDevice).where( - PendingDevice.ip == host["ip"], + PendingDevice.ip == ip, PendingDevice.status == "pending", ) ) @@ -131,7 +167,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: existing.suggested_type = suggested_type else: device = PendingDevice( - ip=host["ip"], + ip=ip, mac=host.get("mac"), hostname=host.get("hostname"), os=host.get("os"), diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index f355f2a..c03b8b4 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import PendingDevice, ScanRun +from app.db.models import Node, PendingDevice, ScanRun from app.services.scanner import run_scan @@ -199,6 +199,119 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession): assert device.suggested_type == "server" +@pytest.mark.asyncio +async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession): + """Pending devices that were already in canvas before scan starts must be removed.""" + node = Node( + id=str(uuid.uuid4()), + label="Existing Server", + type="server", + ip="192.168.1.50", + status="online", + services=[], + pos_x=0.0, + pos_y=0.0, + ) + stale = PendingDevice( + id=str(uuid.uuid4()), + ip="192.168.1.50", + mac=None, + hostname=None, + os=None, + services=[], + suggested_type="generic", + status="pending", + ) + db_session.add(node) + db_session.add(stale) + await db_session.commit() + + 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() + + with ( + patch("app.services.scanner._nmap_scan", return_value=[]), + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock), + ): + await run_scan(["192.168.1.0/24"], db_session, run_id) + + result = await db_session.execute( + select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") + ) + assert result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): + """Devices whose IP already exists as a canvas Node must not appear in pending.""" + node = Node( + id=str(uuid.uuid4()), + label="Existing Server", + type="server", + ip="192.168.1.50", + status="online", + services=[], + pos_x=0.0, + pos_y=0.0, + ) + db_session.add(node) + await db_session.commit() + + 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() + + with ( + patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]), + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock), + ): + await run_scan(["192.168.1.0/24"], db_session, run_id) + + result = await db_session.execute( + select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") + ) + assert result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_run_scan_skips_hidden_device(db_session: AsyncSession): + """Devices previously hidden by the user must not re-appear in pending on re-scan.""" + hidden = PendingDevice( + id=str(uuid.uuid4()), + ip="192.168.1.50", + mac=None, + hostname=None, + os=None, + services=[], + suggested_type="generic", + status="hidden", + ) + db_session.add(hidden) + await db_session.commit() + + 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() + + with ( + patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]), + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock), + ): + await run_scan(["192.168.1.0/24"], db_session, run_id) + + result = await db_session.execute( + select(PendingDevice).where( + PendingDevice.ip == "192.168.1.50", + PendingDevice.status == "pending", + ) + ) + assert result.scalar_one_or_none() is None + + @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.""" From 58381b97d21032531efbefb2122c10c1b0f08a47 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 30 Mar 2026 22:21:36 +0200 Subject: [PATCH 2/5] feat: move status check interval to sidebar Settings panel - Add Settings item in sidebar actions section (below Save Canvas) - Settings panel shows status check interval with save button - Remove interval field from ScanConfigModal (now belongs in Settings) - Scan modal shows a hint pointing to sidebar Settings --- .../src/components/modals/ScanConfigModal.tsx | 18 ++--- frontend/src/components/panels/Sidebar.tsx | 73 ++++++++++++++++++- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/modals/ScanConfigModal.tsx b/frontend/src/components/modals/ScanConfigModal.tsx index 4fe101d..46af678 100644 --- a/frontend/src/components/modals/ScanConfigModal.tsx +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Plus, Trash2 } from 'lucide-react' +import { Plus, Trash2, Settings } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -95,18 +95,10 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro - {/* Status check interval */} -
- - setInterval(Number(e.target.value))} - className="font-mono text-sm bg-[#0d1117] border-border w-32" - /> -
+

+ + Status check interval can be configured in the sidebar Settings. +

diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 3105bdb..6de1bf4 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 } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -9,7 +9,7 @@ import { PendingDeviceModal, type PendingDevice } from '@/components/modals/Pend const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' +type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings' const ALL_VIEWS = [ { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, @@ -95,6 +95,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro {activeView === 'pending' && } {activeView === 'hidden' && } {activeView === 'history' && } + {activeView === 'settings' && } )} @@ -141,6 +142,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro badge={hasUnsavedChanges} accent /> + {!STANDALONE && ( + setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')} + /> + )} ) @@ -417,6 +427,65 @@ function ScanHistoryPanel() { ) } +function SettingsPanel() { + const [interval, setIntervalValue] = useState(60) + const [ranges, setRangesValue] = useState([]) + const [saving, setSaving] = useState(false) + + useEffect(() => { + scanApi.getConfig() + .then((res) => { + setIntervalValue(res.data.interval_seconds) + setRangesValue(res.data.ranges) + }) + .catch(() => {/* use current defaults */}) + }, []) + + const handleSave = async () => { + setSaving(true) + try { + await scanApi.saveConfig({ ranges, interval_seconds: interval }) + toast.success('Settings saved') + } catch { + toast.error('Failed to save settings') + } finally { + setSaving(false) + } + } + + return ( +
+ Settings + +
+ +
+ setIntervalValue(Number(e.target.value))} + className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" + /> + seconds +
+

+ How often node health is polled (ping, HTTP, SSH…) +

+
+ + +
+ ) +} + const MAC_OUI: Record = { '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, From 381f870bb5a636b8297013cacdd7c7e5e37a1c67 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 30 Mar 2026 22:24:30 +0200 Subject: [PATCH 3/5] fix: update ScanConfigModal tests after removing interval field - Replace interval display test with interval-preservation test - Reset saveConfig mock call history in beforeEach to prevent test bleed --- .../modals/__tests__/ScanConfigModal.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 5eaca0c..53d2fa7 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -18,6 +18,7 @@ const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 describe('ScanConfigModal', () => { beforeEach(() => { vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never) + vi.mocked(scanApi.saveConfig).mockReset() vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never) vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() @@ -37,11 +38,14 @@ describe('ScanConfigModal', () => { expect(input).toBeDefined() }) - it('loads interval from API on open', async () => { + it('preserves interval from API when saving ranges', async () => { vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never) render() - const input = await screen.findByDisplayValue('120') - expect(input).toBeDefined() + await screen.findByDisplayValue('10.0.0.0/8') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'], interval_seconds: 120 }) + }) }) it('adds a new empty range on "Add range" click', async () => { From 68c7672cea4c5e1d4e7b4873c19c280ddfc67a25 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 30 Mar 2026 23:06:51 +0200 Subject: [PATCH 4/5] feat: split scan config and app settings into separate endpoints - New GET/POST /api/v1/settings for status check interval - Scan /api/v1/scan/config now handles ranges only - Frontend: settingsApi client, SettingsPanel uses settingsApi - ScanConfigModal no longer reads/writes interval - 4 new backend tests for settings endpoint --- backend/app/api/routes/scan.py | 7 +-- backend/app/api/routes/settings.py | 29 ++++++++++++ backend/app/main.py | 2 + backend/tests/test_settings.py | 47 +++++++++++++++++++ frontend/src/api/client.ts | 9 +++- .../src/components/modals/ScanConfigModal.tsx | 8 +--- .../modals/__tests__/ScanConfigModal.test.tsx | 14 +++--- frontend/src/components/panels/Sidebar.tsx | 14 ++---- 8 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 backend/app/api/routes/settings.py create mode 100644 backend/tests/test_settings.py diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 4118999..7d9a535 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -17,7 +17,6 @@ from app.services.scanner import run_scan class ScanConfig(BaseModel): ranges: list[str] - interval_seconds: int logger = logging.getLogger(__name__) @@ -103,17 +102,13 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur @router.get("/config", response_model=ScanConfig) async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: - return ScanConfig( - ranges=settings.scanner_ranges, - interval_seconds=settings.status_checker_interval, - ) + return ScanConfig(ranges=settings.scanner_ranges) @router.post("/config", response_model=ScanConfig) async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: try: settings.scanner_ranges = payload.ranges - settings.status_checker_interval = payload.interval_seconds settings.save_overrides() return payload except Exception as exc: diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 0000000..5205685 --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -0,0 +1,29 @@ +"""App-level settings (status checker interval, etc.).""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.api.deps import get_current_user +from app.core.config import settings + +router = APIRouter() + + +class AppSettings(BaseModel): + interval_seconds: int + + +@router.get("", response_model=AppSettings) +async def get_settings(_: str = Depends(get_current_user)) -> AppSettings: + return AppSettings(interval_seconds=settings.status_checker_interval) + + +@router.post("", response_model=AppSettings) +async def update_settings( + payload: AppSettings, _: str = Depends(get_current_user) +) -> AppSettings: + try: + settings.status_checker_interval = payload.interval_seconds + settings.save_overrides() + return payload + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/backend/app/main.py b/backend/app/main.py index eabb633..fa7cac8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status +from app.api.routes import settings as settings_routes from app.core.config import settings from app.core.scheduler import start_scheduler, stop_scheduler from app.db.database import init_db @@ -40,6 +41,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"]) app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) +app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"]) app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]) diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py new file mode 100644 index 0000000..a5b8069 --- /dev/null +++ b/backend/tests/test_settings.py @@ -0,0 +1,47 @@ +"""Tests for GET/POST /api/v1/settings.""" +from unittest.mock import patch + +import pytest +from httpx import AsyncClient + + +@pytest.fixture +async def headers(client: AsyncClient): + res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}) + token = res.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_get_settings_requires_auth(client: AsyncClient): + res = await client.get("/api/v1/settings") + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_settings_returns_interval(client: AsyncClient, headers): + res = await client.get("/api/v1/settings", headers=headers) + assert res.status_code == 200 + data = res.json() + assert "interval_seconds" in data + assert isinstance(data["interval_seconds"], int) + + +@pytest.mark.asyncio +async def test_update_settings_saves_interval(client: AsyncClient, headers): + with patch("app.api.routes.settings.settings") as mock_settings: + mock_settings.status_checker_interval = 60 + mock_settings.save_overrides = lambda: None + res = await client.post( + "/api/v1/settings", + json={"interval_seconds": 120}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["interval_seconds"] == 120 + + +@pytest.mark.asyncio +async def test_update_settings_requires_auth(client: AsyncClient): + res = await client.post("/api/v1/settings", json={"interval_seconds": 30}) + assert res.status_code == 401 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bf4f566..5d5f086 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -59,6 +59,11 @@ 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`), - getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'), - saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data), + getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), + saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), +} + +export const settingsApi = { + get: () => api.get<{ interval_seconds: number }>('/settings'), + save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), } diff --git a/frontend/src/components/modals/ScanConfigModal.tsx b/frontend/src/components/modals/ScanConfigModal.tsx index 46af678..0b33b66 100644 --- a/frontend/src/components/modals/ScanConfigModal.tsx +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -15,16 +15,12 @@ interface ScanConfigModalProps { export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { const [ranges, setRanges] = useState(['']) - const [interval, setInterval] = useState(60) const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return scanApi.getConfig() - .then((res) => { - setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']) - setInterval(res.data.interval_seconds) - }) + .then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])) .catch(() => {/* use defaults */}) }, [open]) @@ -33,7 +29,7 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } setSaving(true) try { - await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval }) + await scanApi.saveConfig({ ranges: cleaned }) toast.success('Scan config saved') onClose() } catch { diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 53d2fa7..bf1055d 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -13,7 +13,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f import { scanApi } from '@/api/client' import { toast } from 'sonner' -const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } } +const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } } describe('ScanConfigModal', () => { beforeEach(() => { @@ -38,13 +38,13 @@ describe('ScanConfigModal', () => { expect(input).toBeDefined() }) - it('preserves interval from API when saving ranges', async () => { - vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never) + it('saves only ranges (interval managed by settings endpoint)', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never) render() await screen.findByDisplayValue('10.0.0.0/8') fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { - expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'], interval_seconds: 120 }) + expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] }) }) }) @@ -65,7 +65,7 @@ describe('ScanConfigModal', () => { }) it('can remove a range when more than one exist', async () => { - vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never) + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never) render() await screen.findByDisplayValue('192.168.1.0/24') // Both trash buttons should be enabled @@ -74,7 +74,7 @@ describe('ScanConfigModal', () => { }) it('shows error toast and does not save when all ranges are empty', async () => { - vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never) + vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never) render() await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) fireEvent.click(screen.getByRole('button', { name: 'Save' })) @@ -90,7 +90,7 @@ describe('ScanConfigModal', () => { await screen.findByDisplayValue('192.168.1.0/24') fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { - expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 }) + expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] }) expect(toast.success).toHaveBeenCalledWith('Scan config saved') expect(onClose).toHaveBeenCalledOnce() }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 6de1bf4..14b2e24 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -3,7 +3,7 @@ import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' -import { scanApi } from '@/api/client' +import { scanApi, settingsApi } from '@/api/client' import { toast } from 'sonner' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' @@ -429,22 +429,18 @@ function ScanHistoryPanel() { function SettingsPanel() { const [interval, setIntervalValue] = useState(60) - const [ranges, setRangesValue] = useState([]) const [saving, setSaving] = useState(false) useEffect(() => { - scanApi.getConfig() - .then((res) => { - setIntervalValue(res.data.interval_seconds) - setRangesValue(res.data.ranges) - }) - .catch(() => {/* use current defaults */}) + settingsApi.get() + .then((res) => setIntervalValue(res.data.interval_seconds)) + .catch(() => {/* use default */}) }, []) const handleSave = async () => { setSaving(true) try { - await scanApi.saveConfig({ ranges, interval_seconds: interval }) + await settingsApi.save({ interval_seconds: interval }) toast.success('Settings saved') } catch { toast.error('Failed to save settings') From 350dc14a16dbb65fc7f54299add2b077a296e0aa Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 30 Mar 2026 23:14:42 +0200 Subject: [PATCH 5/5] test: add SettingsPanel tests covering settingsApi integration - Opens panel and calls settingsApi.get - Displays interval loaded from API - Saves updated interval via settingsApi.save - Shows error toast on save failure - Toggles panel closed on second Settings click --- .../panels/__tests__/SettingsPanel.test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 frontend/src/components/panels/__tests__/SettingsPanel.test.tsx diff --git a/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx b/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx new file mode 100644 index 0000000..08a4a04 --- /dev/null +++ b/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx @@ -0,0 +1,104 @@ +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: [] }), + getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), + }, + settingsApi: { + get: vi.fn(), + save: vi.fn(), + }, +})) + +import { settingsApi } from '@/api/client' +import { toast } from 'sonner' + +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( + + + + ) +} + +describe('SettingsPanel', () => { + beforeEach(() => { + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never) + vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never) + vi.mocked(toast.success).mockReset() + vi.mocked(toast.error).mockReset() + }) + + it('opens when Settings item is clicked', async () => { + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + await waitFor(() => { + expect(settingsApi.get).toHaveBeenCalledOnce() + }) + expect(screen.getByText('Status check interval (s)')).toBeDefined() + }) + + it('displays interval loaded from API', async () => { + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never) + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + const input = await screen.findByDisplayValue('120') + expect(input).toBeDefined() + }) + + it('saves interval via settingsApi on Save click', async () => { + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + const input = await screen.findByDisplayValue('60') + fireEvent.change(input, { target: { value: '180' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 }) + expect(toast.success).toHaveBeenCalledWith('Settings saved') + }) + }) + + it('shows error toast when save fails', async () => { + vi.mocked(settingsApi.save).mockRejectedValue(new Error('network')) + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + await screen.findByDisplayValue('60') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to save settings') + }) + }) + + it('closes panel when Settings is clicked again', async () => { + renderSidebar() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + await screen.findByText('Status check interval (s)') + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + expect(screen.queryByText('Status check interval (s)')).toBeNull() + }) +})