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):
|
class ScanConfig(BaseModel):
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
interval_seconds: int
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.get("/config", response_model=ScanConfig)
|
||||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
return ScanConfig(
|
return ScanConfig(ranges=settings.scanner_ranges)
|
||||||
ranges=settings.scanner_ranges,
|
|
||||||
interval_seconds=settings.status_checker_interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
try:
|
try:
|
||||||
settings.scanner_ranges = payload.ranges
|
settings.scanner_ranges = payload.ranges
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
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.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.db.database import init_db
|
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(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
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(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"])
|
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),
|
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data),
|
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) {
|
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
||||||
const [ranges, setRanges] = useState<string[]>([''])
|
const [ranges, setRanges] = useState<string[]>([''])
|
||||||
const [interval, setInterval] = useState(60)
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
scanApi.getConfig()
|
scanApi.getConfig()
|
||||||
.then((res) => {
|
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
|
||||||
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
|
|
||||||
setInterval(res.data.interval_seconds)
|
|
||||||
})
|
|
||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [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 }
|
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
|
await scanApi.saveConfig({ ranges: cleaned })
|
||||||
toast.success('Scan config saved')
|
toast.success('Scan config saved')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f
|
|||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
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', () => {
|
describe('ScanConfigModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,13 +38,13 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(input).toBeDefined()
|
expect(input).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves interval from API when saving ranges', async () => {
|
it('saves only ranges (interval managed by settings endpoint)', async () => {
|
||||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
|
||||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('10.0.0.0/8')
|
await screen.findByDisplayValue('10.0.0.0/8')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
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 () => {
|
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()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
// Both trash buttons should be enabled
|
// 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 () => {
|
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()} />)
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
@@ -90,7 +90,7 @@ describe('ScanConfigModal', () => {
|
|||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
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(toast.success).toHaveBeenCalledWith('Scan config saved')
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock
|
|||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
@@ -429,22 +429,18 @@ function ScanHistoryPanel() {
|
|||||||
|
|
||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [ranges, setRangesValue] = useState<string[]>([])
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scanApi.getConfig()
|
settingsApi.get()
|
||||||
.then((res) => {
|
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||||
setIntervalValue(res.data.interval_seconds)
|
.catch(() => {/* use default */})
|
||||||
setRangesValue(res.data.ranges)
|
|
||||||
})
|
|
||||||
.catch(() => {/* use current defaults */})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await scanApi.saveConfig({ ranges, interval_seconds: interval })
|
await settingsApi.save({ interval_seconds: interval })
|
||||||
toast.success('Settings saved')
|
toast.success('Settings saved')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save settings')
|
toast.error('Failed to save settings')
|
||||||
|
|||||||
Reference in New Issue
Block a user