Merge pull request #29 from Pouzor/feat/scan-dedup-skip-canvas
feat: scan dedup, skip canvas/hidden nodes, 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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -107,18 +107,54 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
try:
|
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:
|
for cidr in ranges:
|
||||||
# Run nmap in a thread pool — does not block the event loop
|
# Run nmap in a thread pool — does not block the event loop
|
||||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
||||||
|
|
||||||
for host in hosts:
|
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"])
|
services = fingerprint_ports(host["open_ports"])
|
||||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||||
|
|
||||||
# Update existing pending device or create a new one
|
# Update existing pending device or create a new one
|
||||||
existing_result = await db.execute(
|
existing_result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.ip == host["ip"],
|
PendingDevice.ip == ip,
|
||||||
PendingDevice.status == "pending",
|
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
|
existing.suggested_type = suggested_type
|
||||||
else:
|
else:
|
||||||
device = PendingDevice(
|
device = PendingDevice(
|
||||||
ip=host["ip"],
|
ip=ip,
|
||||||
mac=host.get("mac"),
|
mac=host.get("mac"),
|
||||||
hostname=host.get("hostname"),
|
hostname=host.get("hostname"),
|
||||||
os=host.get("os"),
|
os=host.get("os"),
|
||||||
|
|||||||
+114
-1
@@ -7,7 +7,7 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -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 {
|
||||||
@@ -95,18 +91,10 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status check interval */}
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
<div className="space-y-1.5">
|
<Settings size={11} />
|
||||||
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
|
Status check interval can be configured in the sidebar Settings.
|
||||||
<Input
|
</p>
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={3600}
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => setInterval(Number(e.target.value))}
|
|
||||||
className="font-mono text-sm bg-[#0d1117] border-border w-32"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ 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(() => {
|
||||||
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||||
|
vi.mocked(scanApi.saveConfig).mockReset()
|
||||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
@@ -37,11 +38,14 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(input).toBeDefined()
|
expect(input).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads interval from API on open', 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()} />)
|
||||||
const input = await screen.findByDisplayValue('120')
|
await screen.findByDisplayValue('10.0.0.0/8')
|
||||||
expect(input).toBeDefined()
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds a new empty range on "Add range" click', async () => {
|
it('adds a new empty range on "Add range" click', async () => {
|
||||||
@@ -61,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
|
||||||
@@ -70,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' }))
|
||||||
@@ -86,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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
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 { 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'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
|
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
||||||
|
|
||||||
const ALL_VIEWS = [
|
const ALL_VIEWS = [
|
||||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
||||||
@@ -95,6 +95,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
|
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -141,6 +142,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
badge={hasUnsavedChanges}
|
badge={hasUnsavedChanges}
|
||||||
accent
|
accent
|
||||||
/>
|
/>
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={Settings}
|
||||||
|
label="Settings"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'settings'}
|
||||||
|
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
@@ -417,6 +427,61 @@ function ScanHistoryPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsPanel() {
|
||||||
|
const [interval, setIntervalValue] = useState(60)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi.get()
|
||||||
|
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||||
|
.catch(() => {/* use default */})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await settingsApi.save({ interval_seconds: interval })
|
||||||
|
toast.success('Settings saved')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save settings')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-4">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={3600}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||||
|
|||||||
@@ -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<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
onAddNode={vi.fn()}
|
||||||
|
onAddGroupRect={vi.fn()}
|
||||||
|
onScan={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onNodeApproved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user