This commit is contained in:
iqubik
2026-03-25 21:40:56 +03:00
parent 241e9c5af7
commit ed1cd84aba
5 changed files with 474 additions and 8 deletions
+285 -3
View File
@@ -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>
);
}
}