feat: add Deep Scan toggle to scan dialog

Collapsible Deep Scan section in ScanConfigModal exposes extra port ranges,
HTTP probe and TLS-verify switches. Pre-filled from saved defaults; edits are
passed to trigger() as a per-scan override and do not change the persisted
defaults (those live in the Options/scan config). scanApi.trigger now accepts
an optional deep-scan body.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-24 10:31:32 +02:00
parent b6423c0115
commit d01630bf37
3 changed files with 160 additions and 9 deletions
+11 -3
View File
@@ -58,8 +58,16 @@ export const liveviewApi = {
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'), getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
} }
export interface DeepScanConfig {
http_ranges: string[]
http_probe_enabled: boolean
verify_tls: boolean
}
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
export const scanApi = { export const scanApi = {
trigger: () => api.post('/scan/trigger'), trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'), pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'), hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'), runs: () => api.get('/scan/runs'),
@@ -86,8 +94,8 @@ export const scanApi = {
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`), restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }), bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`), stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), getConfig: () => api.get<ScanConfigData>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
} }
export interface AppSettings { export interface AppSettings {
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Settings } from 'lucide-react' import { Plus, Trash2, Settings, ChevronRight, ChevronDown } 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'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { scanApi } from '@/api/client' import { scanApi, type DeepScanConfig } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
interface ScanConfigModalProps { interface ScanConfigModalProps {
@@ -13,24 +13,56 @@ interface ScanConfigModalProps {
onScanNow: () => void onScanNow: () => void
} }
const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false }
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 [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Deep-scan section. Pre-filled from persisted defaults; edits here are a
// per-scan override passed to trigger() — they do NOT change the saved defaults.
const [deepOpen, setDeepOpen] = useState(false)
const [deepDefaults, setDeepDefaults] = useState<DeepScanConfig>(DEEP_DEFAULTS)
const [httpProbe, setHttpProbe] = useState(false)
const [verifyTls, setVerifyTls] = useState(false)
const [httpRangesText, setHttpRangesText] = useState('')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
scanApi.getConfig() scanApi.getConfig()
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])) .then((res) => {
const d = res.data
setRanges(d.ranges.length > 0 ? d.ranges : [''])
const deep: DeepScanConfig = {
http_ranges: d.http_ranges ?? [],
http_probe_enabled: d.http_probe_enabled ?? false,
verify_tls: d.verify_tls ?? false,
}
setDeepDefaults(deep)
setHttpProbe(deep.http_probe_enabled)
setVerifyTls(deep.verify_tls)
setHttpRangesText(deep.http_ranges.join(', '))
setDeepOpen(deep.http_probe_enabled || deep.http_ranges.length > 0)
})
.catch(() => {/* use defaults */}) .catch(() => {/* use defaults */})
}, [open]) }, [open])
const parseHttpRanges = () =>
httpRangesText.split(',').map((r) => r.trim()).filter(Boolean)
const handleScanNow = 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 }) // Persist IP ranges; leave deep-scan defaults as configured in Options.
await scanApi.trigger() await scanApi.saveConfig({ ranges: cleaned, ...deepDefaults })
// Per-scan deep-scan override from this dialog.
await scanApi.trigger({
http_ranges: parseHttpRanges(),
http_probe_enabled: httpProbe,
verify_tls: verifyTls,
})
onScanNow() onScanNow()
onClose() onClose()
} catch { } catch {
@@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button> </Button>
</div> </div>
{/* Deep Scan (opt-in) */}
<div className="space-y-2 border-t border-border pt-3">
<button
type="button"
onClick={() => setDeepOpen((v) => !v)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
{deepOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Deep Scan
</button>
{deepOpen && (
<div className="space-y-3 pl-1">
<p className="text-xs text-muted-foreground">
Scan extra ports and probe HTTP services to identify apps on custom ports.
Overrides the saved defaults for this scan only.
</p>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Extra port ranges</Label>
<Input
value={httpRangesText}
onChange={(e) => setHttpRangesText(e.target.value)}
placeholder="8000-8100, 9000-9100"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={httpProbe}
onChange={(e) => setHttpProbe(e.target.checked)}
className="accent-[#00d4ff]"
/>
Enable HTTP probe
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
className="accent-[#00d4ff]"
/>
Verify TLS certificates
</label>
</div>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5"> <p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Settings size={11} /> <Settings size={11} />
Status check interval can be configured in the sidebar Settings. Status check interval can be configured in the sidebar Settings.
@@ -82,7 +82,12 @@ describe('ScanConfigModal', () => {
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
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'],
http_ranges: [],
http_probe_enabled: false,
verify_tls: false,
})
expect(scanApi.trigger).toHaveBeenCalledOnce() expect(scanApi.trigger).toHaveBeenCalledOnce()
expect(onScanNow).toHaveBeenCalledOnce() expect(onScanNow).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
@@ -108,4 +113,59 @@ describe('ScanConfigModal', () => {
) )
}) })
}) })
// --- Deep scan ---
it('reveals deep-scan fields when the section is toggled', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
expect(screen.queryByText('Enable HTTP probe')).toBeNull()
fireEvent.click(screen.getByText('Deep Scan'))
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
})
it('passes deep-scan overrides to trigger() as a per-scan override', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByText('Deep Scan'))
fireEvent.change(screen.getByPlaceholderText('8000-8100, 9000-9100'), {
target: { value: '8000-8100, 9000' },
})
fireEvent.click(screen.getByLabelText('Enable HTTP probe'))
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.trigger).toHaveBeenCalledWith({
http_ranges: ['8000-8100', '9000'],
http_probe_enabled: true,
verify_tls: false,
})
})
})
it('auto-opens deep-scan section when a default probe is enabled', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: false },
} as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
expect(screen.getByDisplayValue('7000-7100')).toBeDefined()
})
it('saving keeps deep-scan defaults untouched (modal only overrides per-scan)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: true },
} as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({
ranges: ['192.168.1.0/24'],
http_ranges: ['7000-7100'],
http_probe_enabled: true,
verify_tls: true,
})
})
})
}) })