diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 96a8ebe..22b8e8c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,8 +58,16 @@ export const liveviewApi = { 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 = { - trigger: () => api.post('/scan/trigger'), + trigger: (deepScan?: Partial) => api.post('/scan/trigger', deepScan ?? {}), pending: () => api.get('/scan/pending'), hidden: () => api.get('/scan/hidden'), 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`), bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }), stop: (runId: string) => api.post(`/scan/${runId}/stop`), - getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), - saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), + getConfig: () => api.get('/scan/config'), + saveConfig: (data: ScanConfigData) => api.post('/scan/config', data), } export interface AppSettings { diff --git a/frontend/src/components/modals/ScanConfigModal.tsx b/frontend/src/components/modals/ScanConfigModal.tsx index f1d3742..c9b779a 100644 --- a/frontend/src/components/modals/ScanConfigModal.tsx +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -1,10 +1,10 @@ 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 { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { scanApi } from '@/api/client' +import { scanApi, type DeepScanConfig } from '@/api/client' import { toast } from 'sonner' interface ScanConfigModalProps { @@ -13,24 +13,56 @@ interface ScanConfigModalProps { onScanNow: () => void } +const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false } + export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { const [ranges, setRanges] = useState(['']) 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(DEEP_DEFAULTS) + const [httpProbe, setHttpProbe] = useState(false) + const [verifyTls, setVerifyTls] = useState(false) + const [httpRangesText, setHttpRangesText] = useState('') + useEffect(() => { if (!open) return 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 */}) }, [open]) + const parseHttpRanges = () => + httpRangesText.split(',').map((r) => r.trim()).filter(Boolean) + 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 } setSaving(true) try { - await scanApi.saveConfig({ ranges: cleaned }) - await scanApi.trigger() + // Persist IP ranges; leave deep-scan defaults as configured in Options. + 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() onClose() } catch { @@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro + {/* Deep Scan (opt-in) */} +
+ + + {deepOpen && ( +
+

+ Scan extra ports and probe HTTP services to identify apps on custom ports. + Overrides the saved defaults for this scan only. +

+ +
+ + setHttpRangesText(e.target.value)} + placeholder="8000-8100, 9000-9100" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+ + + + +
+ )} +
+

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 988be69..8bdb4e8 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -82,7 +82,12 @@ describe('ScanConfigModal', () => { 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'] }) + expect(scanApi.saveConfig).toHaveBeenCalledWith({ + ranges: ['192.168.1.0/24'], + http_ranges: [], + http_probe_enabled: false, + verify_tls: false, + }) expect(scanApi.trigger).toHaveBeenCalledOnce() expect(onScanNow).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce() @@ -108,4 +113,59 @@ describe('ScanConfigModal', () => { ) }) }) + + // --- Deep scan --- + + it('reveals deep-scan fields when the section is toggled', async () => { + render() + 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() + 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() + 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() + 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, + }) + }) + }) })