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')