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/app/services/scanner.py b/backend/app/services/scanner.py index 85a27ea..70e236f 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -7,7 +7,7 @@ from typing import Any 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 logger = logging.getLogger(__name__) @@ -107,18 +107,54 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: devices_found = 0 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: # Run nmap in a thread pool — does not block the event loop hosts = await asyncio.to_thread(_nmap_scan, cidr) 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"]) suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) # Update existing pending device or create a new one existing_result = await db.execute( select(PendingDevice).where( - PendingDevice.ip == host["ip"], + PendingDevice.ip == ip, 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 else: device = PendingDevice( - ip=host["ip"], + ip=ip, mac=host.get("mac"), hostname=host.get("hostname"), os=host.get("os"), diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index f355f2a..c03b8b4 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from sqlalchemy import select 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 @@ -199,6 +199,119 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession): 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 async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession): """Re-scanning the same IP updates services instead of creating a duplicate.""" 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 4fe101d..0b33b66 100644 --- a/frontend/src/components/modals/ScanConfigModal.tsx +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -1,5 +1,5 @@ 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 { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -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 { @@ -95,18 +91,10 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro - {/* Status check interval */} -
- - setInterval(Number(e.target.value))} - className="font-mono text-sm bg-[#0d1117] border-border w-32" - /> -
+

+ + Status check interval can be configured in the sidebar Settings. +

diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 5eaca0c..bf1055d 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -13,11 +13,12 @@ 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(() => { vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never) + vi.mocked(scanApi.saveConfig).mockReset() vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never) vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() @@ -37,11 +38,14 @@ describe('ScanConfigModal', () => { expect(input).toBeDefined() }) - it('loads interval from API on open', 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() - const input = await screen.findByDisplayValue('120') - expect(input).toBeDefined() + 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'] }) + }) }) 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 () => { - 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 @@ -70,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' })) @@ -86,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 3105bdb..14b2e24 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,15 +1,15 @@ 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 { 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' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' +type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings' const ALL_VIEWS = [ { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, @@ -95,6 +95,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro {activeView === 'pending' && } {activeView === 'hidden' && } {activeView === 'history' && } + {activeView === 'settings' && } )} @@ -141,6 +142,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro badge={hasUnsavedChanges} accent /> + {!STANDALONE && ( + setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')} + /> + )} ) @@ -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 ( +
+ Settings + +
+ +
+ 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]" + /> + seconds +
+

+ How often node health is polled (ping, HTTP, SSH…) +

+
+ + +
+ ) +} + const MAC_OUI: Record = { '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, diff --git a/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx b/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx new file mode 100644 index 0000000..08a4a04 --- /dev/null +++ b/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx @@ -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) + + return render( + + + + ) +} + +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() + }) +})