Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd18ab543 | |||
| 4d8bb246f1 | |||
| 07da498d18 |
+1
-1
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.8.1",
|
version="1.8.2",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -57,8 +58,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
else:
|
||||||
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ping", "-c", "1", "-W", "1", host,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _tcp_connect, check_node
|
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
|
|||||||
assert result["response_time_ms"] is None
|
assert result["response_time_ms"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping platform args ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_unix_args_on_non_windows():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
assert "-n" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_windows_args_on_win32():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-n" in captured["args"]
|
||||||
|
assert "-w" in captured["args"]
|
||||||
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
@@ -454,7 +454,11 @@ export default function App() {
|
|||||||
<ScanConfigModal
|
<ScanConfigModal
|
||||||
open={scanConfigOpen}
|
open={scanConfigOpen}
|
||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => toast.success('Scan triggered')}
|
onScanNow={() => {
|
||||||
|
toast.success('Network scan started — check Scan History for results')
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,29 +24,22 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleScanNow = async () => {
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||||
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 })
|
await scanApi.saveConfig({ ranges: cleaned })
|
||||||
toast.success('Scan config saved')
|
await scanApi.trigger()
|
||||||
|
onScanNow()
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save config')
|
toast.error('Failed to start scan')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanNow = async () => {
|
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
|
||||||
await handleSave()
|
|
||||||
onScanNow()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
scanApi: {
|
scanApi: {
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
saveConfig: vi.fn(),
|
saveConfig: vi.fn(),
|
||||||
|
trigger: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
@@ -20,6 +21,8 @@ describe('ScanConfigModal', () => {
|
|||||||
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).mockReset()
|
||||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(scanApi.trigger).mockReset()
|
||||||
|
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
})
|
})
|
||||||
@@ -72,7 +75,7 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
|
||||||
const onScanNow = vi.fn()
|
const onScanNow = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||||
@@ -80,7 +83,9 @@ describe('ScanConfigModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||||
|
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||||
expect(onScanNow).toHaveBeenCalledOnce()
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(() => {
|
||||||
try {
|
onScan()
|
||||||
await scanApi.trigger()
|
|
||||||
toast.success('Network scan started — check Scan History for results')
|
|
||||||
setActiveView('history')
|
|
||||||
onScan()
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to trigger scan')
|
|
||||||
}
|
|
||||||
}, [onScan])
|
}, [onScan])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -222,20 +222,12 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => {
|
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||||
const { scanApi } = await import('@/api/client')
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce())
|
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to Scan History view after scan is triggered', async () => {
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
|
||||||
await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user