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
This commit is contained in:
Pouzor
2026-03-30 23:06:51 +02:00
parent 381f870bb5
commit 68c7672cea
8 changed files with 100 additions and 30 deletions
+1 -6
View File
@@ -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:
+29
View File
@@ -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
+2
View File
@@ -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"])
+47
View File
@@ -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
+7 -2
View File
@@ -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),
}
@@ -15,16 +15,12 @@ interface ScanConfigModalProps {
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>([''])
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 {
@@ -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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
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()
})
+5 -9
View File
@@ -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<string[]>([])
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')