0
This commit is contained in:
@@ -1,9 +1,29 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery } from '@mui/material';
|
||||
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
|
||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
|
||||
import { Delete, Add, UploadFile, Remove, ExpandMore } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
interface ScanCapabilities {
|
||||
scannerAvailable: boolean;
|
||||
scannerPath: string | null;
|
||||
timeoutAvailable: boolean;
|
||||
timeoutPath: string | null;
|
||||
}
|
||||
interface ScanResponse {
|
||||
addr: string;
|
||||
scanSeconds: number;
|
||||
thread: number;
|
||||
timeout: number;
|
||||
timedOut: boolean;
|
||||
exitCode: number;
|
||||
foundCount: number;
|
||||
domains: string[];
|
||||
stderrTail: string;
|
||||
stdoutTail: string;
|
||||
}
|
||||
|
||||
const SCAN_STORAGE_KEY = 'domains_scan_state_v1';
|
||||
|
||||
export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
@@ -15,6 +35,17 @@ export default function DomainsPage() {
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [scanCapabilities, setScanCapabilities] = useState<ScanCapabilities | null>(null);
|
||||
const [scanAddr, setScanAddr] = useState('');
|
||||
const [scanSeconds, setScanSeconds] = useState(30);
|
||||
const [scanThread, setScanThread] = useState(2);
|
||||
const [scanTimeout, setScanTimeout] = useState(5);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState('');
|
||||
const [scanResult, setScanResult] = useState<ScanResponse | null>(null);
|
||||
const [scanCandidates, setScanCandidates] = useState<string[]>([]);
|
||||
const [scanPanelExpanded, setScanPanelExpanded] = useState(false);
|
||||
const [scanStateHydrated, setScanStateHydrated] = useState(false);
|
||||
|
||||
const loadDomains = async () => {
|
||||
try {
|
||||
@@ -31,6 +62,79 @@ export default function DomainsPage() {
|
||||
loadDomains();
|
||||
}, [page, rowsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScannerContext = async () => {
|
||||
try {
|
||||
const capRes = await api.get('/domains/scan/capabilities');
|
||||
setScanCapabilities(capRes.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsRes = await api.get('/settings');
|
||||
const defaultAddr = settingsRes.data?.xui_ip || settingsRes.data?.xui_host || '';
|
||||
if (defaultAddr) {
|
||||
setScanAddr((prev) => (prev.trim() ? prev : defaultAddr));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
loadScannerContext();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SCAN_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw) as {
|
||||
scanAddr?: string;
|
||||
scanSeconds?: number;
|
||||
scanThread?: number;
|
||||
scanTimeout?: number;
|
||||
scanResult?: ScanResponse | null;
|
||||
scanCandidates?: string[];
|
||||
scanPanelExpanded?: boolean;
|
||||
};
|
||||
|
||||
if (typeof parsed.scanAddr === 'string' && parsed.scanAddr.trim()) setScanAddr(parsed.scanAddr);
|
||||
if (typeof parsed.scanSeconds === 'number') setScanSeconds(parsed.scanSeconds);
|
||||
if (typeof parsed.scanThread === 'number') setScanThread(parsed.scanThread);
|
||||
if (typeof parsed.scanTimeout === 'number') setScanTimeout(parsed.scanTimeout);
|
||||
if (parsed.scanResult) setScanResult(parsed.scanResult);
|
||||
if (Array.isArray(parsed.scanCandidates)) setScanCandidates(parsed.scanCandidates);
|
||||
if (typeof parsed.scanPanelExpanded === 'boolean') setScanPanelExpanded(parsed.scanPanelExpanded);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setScanStateHydrated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanStateHydrated) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SCAN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
scanAddr,
|
||||
scanSeconds,
|
||||
scanThread,
|
||||
scanTimeout,
|
||||
scanResult,
|
||||
scanCandidates,
|
||||
scanPanelExpanded,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]);
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
@@ -85,10 +189,188 @@ export default function DomainsPage() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (!scanAddr.trim()) {
|
||||
alert('Укажите IP/домен для сканирования');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsScanning(true);
|
||||
setScanError('');
|
||||
setScanResult(null);
|
||||
|
||||
const { data } = await api.post('/domains/scan/start', {
|
||||
addr: scanAddr.trim(),
|
||||
scanSeconds,
|
||||
thread: scanThread,
|
||||
timeout: scanTimeout,
|
||||
});
|
||||
|
||||
setScanResult(data);
|
||||
setScanCandidates(data.domains || []);
|
||||
} catch (e: any) {
|
||||
const message = e?.response?.data?.message || e?.message || 'Ошибка запуска сканера';
|
||||
setScanError(Array.isArray(message) ? message.join('; ') : message);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportScannedDomains = async () => {
|
||||
const found = scanCandidates;
|
||||
if (found.length === 0) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/domains/upload', { domains: found });
|
||||
alert(`Скан завершен. Добавлено новых доменов: ${data.count}`);
|
||||
loadDomains();
|
||||
} catch (_e) {
|
||||
alert('Ошибка импорта найденных доменов');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveScannedDomain = (domain: string) => {
|
||||
setScanCandidates((prev) => prev.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
const handleClearScannedDomains = () => {
|
||||
setScanCandidates([]);
|
||||
setScanResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
|
||||
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Accordion
|
||||
expanded={scanPanelExpanded}
|
||||
onChange={(_event, expanded) => setScanPanelExpanded(expanded)}
|
||||
disableGutters
|
||||
sx={{
|
||||
boxShadow: 'none',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography variant='h6'>Автопоиск SNI (backend scanner)</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pb: 2 }}>
|
||||
|
||||
{scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
|
||||
<Alert severity='warning' sx={{ mb: 2 }}>
|
||||
Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label='IP/домен VPS'
|
||||
value={scanAddr}
|
||||
onChange={(e) => setScanAddr(e.target.value)}
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<TextField
|
||||
label='Секунд скана'
|
||||
type='number'
|
||||
value={scanSeconds}
|
||||
onChange={(e) => setScanSeconds(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 140 }}
|
||||
/>
|
||||
<TextField
|
||||
label='Потоков'
|
||||
type='number'
|
||||
value={scanThread}
|
||||
onChange={(e) => setScanThread(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
label='Таймаут'
|
||||
type='number'
|
||||
value={scanTimeout}
|
||||
onChange={(e) => setScanTimeout(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction='row' spacing={1} sx={{ mt: 2, flexWrap: 'wrap' }}>
|
||||
<Button variant='contained' onClick={handleStartScan} disabled={isScanning}>
|
||||
{isScanning ? 'Сканирование...' : 'Сканировать'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={handleImportScannedDomains}
|
||||
disabled={!scanResult || scanCandidates.length === 0 || isScanning}
|
||||
>
|
||||
Добавить найденные в список
|
||||
</Button>
|
||||
<Button
|
||||
variant='text'
|
||||
color='error'
|
||||
onClick={handleClearScannedDomains}
|
||||
disabled={scanCandidates.length === 0 && !scanResult}
|
||||
>
|
||||
Очистить предварительный
|
||||
</Button>
|
||||
{isScanning && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
|
||||
{scanError && <Alert severity='error' sx={{ mt: 2 }}>{scanError}</Alert>}
|
||||
|
||||
{scanResult && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant='body2' sx={{ mb: 1 }}>
|
||||
Найдено: <b>{scanResult.foundCount}</b>. К отбору: <b>{scanCandidates.length}</b>. Код выхода: <b>{scanResult.exitCode}</b>. Таймаут: <b>{String(scanResult.timedOut)}</b>
|
||||
</Typography>
|
||||
<Alert severity='info' sx={{ mb: 1 }}>
|
||||
Можно открыть домен в новом окне, проверить вручную и удалить из предварительного списка перед импортом.
|
||||
</Alert>
|
||||
<Paper variant='outlined' sx={{ maxHeight: 220, overflow: 'auto' }}>
|
||||
<List dense>
|
||||
{scanCandidates.map((d) => (
|
||||
<ListItem
|
||||
key={d}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 120ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
secondaryAction={
|
||||
<IconButton edge='end' onClick={() => handleRemoveScannedDomain(d)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<MuiLink href={`https://${d}`} target='_blank' rel='noopener noreferrer' underline='hover'>
|
||||
{d}
|
||||
</MuiLink>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{scanCandidates.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary='Домены не найдены' />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Доменное имя" size="small" fullWidth
|
||||
@@ -160,4 +442,4 @@ export default function DomainsPage() {
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user