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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user