refactor: massive codebase stabilization, strict TS, UI/UX overhaul, and central logging

This commit is contained in:
iqubik
2026-03-28 18:33:13 +03:00
parent 388417cec9
commit 8f8a3bf724
69 changed files with 4272 additions and 1693 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ function App() {
<Route element={<PublicRoute />}>
<Route path="/login" element={<LoginPage />} />
</Route>
<Route path="/" element={
<RequireAuth>
<Layout />
+3 -8
View File
@@ -1,15 +1,10 @@
/* eslint-disable react-refresh/only-export-components -- экспорты констант и хука вне компонента */
import React, { createContext, useState, useMemo, useContext, useEffect } from 'react';
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import useMediaQuery from '@mui/material/useMediaQuery';
import { getDesignTokens } from './theme';
type ColorMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
mode: ColorMode;
toggleColorMode: () => void;
}
import { getDesignTokens } from './theme';
import type { ColorMode, ThemeContextType } from './types/theme';
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
+27 -2
View File
@@ -1,7 +1,32 @@
import axios from 'axios';
import { Logger } from './utils/logger';
const api = axios.create({
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
});
export default api;
// Interceptor для добавления токена к каждому запросу
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.set('Authorization', `Bearer ${token}`);
}
Logger.debug(`${config.method?.toUpperCase()} ${config.url} | Token: ${token ? 'EXISTS' : 'NULL'}`, 'API');
return config;
});
// Interceptor для ответа
api.interceptors.response.use(
(response) => {
Logger.debug(`${response.status} OK (${response.config.method?.toUpperCase()} ${response.config.url})`, 'API');
return response;
},
(error) => {
const status = error.response?.status;
const message = error.response?.data?.message || error.message || 'Unknown error';
Logger.error(`ERROR ${status || 'NETWORK'}: ${message} (${error.config?.method?.toUpperCase()} ${error.config?.url})`, 'API');
return Promise.reject(error);
}
);
export default api;
+4 -19
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import api from '../api';
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useState } from 'react';
interface AuthContextType {
token: string | null;
@@ -20,37 +20,22 @@ export const useAuth = () => {
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(() => {
const savedToken = localStorage.getItem('token');
if (savedToken) {
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
}
return savedToken;
return localStorage.getItem('token');
});
const login = (newToken: string) => {
localStorage.setItem('token', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
setToken(newToken);
};
const logout = () => {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
setToken(null);
};
useEffect(() => {
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete api.defaults.headers.common['Authorization'];
}
}, [token]);
return (
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
};
};
+12 -5
View File
@@ -1,20 +1,27 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api';
import { useAuth } from './AuthContext';
import { Logger } from '../utils/logger';
export function AxiosInterceptor() {
const { logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const interceptor = api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
console.warn('Session expired or unauthorized. Logging out...');
logout();
navigate('/login');
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
// Не делаем logout если уже на странице логина
if (location.pathname !== '/login') {
Logger.debug('Calling logout()', 'AxiosInterceptor');
logout();
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
navigate('/login');
}
}
return Promise.reject(error);
}
@@ -23,7 +30,7 @@ export function AxiosInterceptor() {
return () => {
api.interceptors.response.eject(interceptor);
};
}, [logout, navigate]);
}, [logout, navigate, location.pathname]);
return null;
}
+32 -5
View File
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom';
import { useThemeContext } from '../ThemeContext';
import { useAuth } from '../auth/AuthContext';
import { Menu as MenuIcon } from '@mui/icons-material';
import { APP_VERSION } from '../utils/version';
interface HeaderProps {
onMenuClick?: () => void;
@@ -23,12 +24,17 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
const navigate = useNavigate();
const [helpOpen, setHelpOpen] = useState(false);
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
const handleLogout = () => {
if (confirm('Вы действительно хотите выйти?')) {
logout();
navigate('/login');
}
setConfirmDialog({
open: true,
title: 'Вы действительно хотите выйти?',
onConfirm: () => {
logout();
navigate('/login');
}
});
};
const getThemeIcon = () => {
@@ -132,7 +138,7 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
</List>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Версия: 2.0.2<br />
Версия: {APP_VERSION}<br />
Разработчик: DenPiligrim
</Typography>
</DialogContent>
@@ -140,6 +146,27 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
<Button onClick={() => setHelpOpen(false)}>Понятно</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog for logout */}
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение</DialogTitle>
<DialogContent>
<Typography>{confirmDialog.title}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
<Button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog({ ...confirmDialog, open: false });
}}
variant="contained"
color="error"
>
Выйти
</Button>
</DialogActions>
</Dialog>
</>
);
}
+223 -83
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
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 React, { useEffect, useRef, useState, useCallback } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails, Snackbar, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import { Delete, Add, UploadFile, Remove, ExpandMore, Download } from '@mui/icons-material';
import api from '../api';
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
import { Logger } from '../utils/logger';
interface Domain { id: number; name: string; }
interface ScanCapabilities {
@@ -68,6 +70,12 @@ export default function DomainsPage() {
const [scanStatus, setScanStatus] = useState<ScanStatusResponse | null>(null);
const [activeScanRunId, setActiveScanRunId] = useState<string | null>(null);
// Snackbar state for notifications
const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' });
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
const clampInteger = (value: number, fallback: number, min: number, max: number) => {
const num = Number.isFinite(value) ? Math.floor(value) : fallback;
if (num < min) return min;
@@ -75,12 +83,18 @@ export default function DomainsPage() {
return num;
};
const isLoopbackHost = (value: string) => {
const isLoopbackHost = useCallback((value: string) => {
const host = value.trim().toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
};
}, []);
const collectAddrCandidatesFromSettings = (settings: any) => {
interface Settings {
xui_ip?: string;
xui_host?: string;
xui_url?: string;
}
const collectAddrCandidatesFromSettings = useCallback((settings: Settings) => {
const candidates: string[] = [];
const xuiIp = String(settings?.xui_ip || '').trim();
const xuiHost = String(settings?.xui_host || '').trim();
@@ -95,53 +109,84 @@ export default function DomainsPage() {
if (parsed.hostname) {
candidates.push(parsed.hostname.trim());
}
} catch (_e) {
} catch {
// Ignore malformed URL from settings and fall back to runtime hostname.
}
}
return candidates.filter(Boolean);
};
}, []);
const resolveSuggestedScanAddr = async (opts?: { allowLoopbackFallback?: boolean }) => {
const resolveSuggestedScanAddr = useCallback(async (opts?: { allowLoopbackFallback?: boolean }) => {
const allowLoopbackFallback = Boolean(opts?.allowLoopbackFallback);
let settingsCandidates: string[] = [];
try {
const settingsRes = await api.get('/settings');
Logger.debug('Domains page: Settings response', 'Domains', settingsRes.data);
settingsCandidates = collectAddrCandidatesFromSettings(settingsRes.data);
Logger.debug('Domains page: Collected address candidates from settings', 'Domains', {
candidates: settingsCandidates,
xui_ip: settingsRes.data?.xui_ip,
xui_host: settingsRes.data?.xui_host,
xui_url: settingsRes.data?.xui_url
});
const publicFromSettings = settingsCandidates.find((c) => !isLoopbackHost(c));
Logger.debug('Domains page: Looking for public address', 'Domains', {
publicFromSettings,
allCandidates: settingsCandidates
});
if (publicFromSettings) {
return publicFromSettings;
}
} catch (e) {
console.error(e);
} catch (error) {
Logger.error('Failed to collect address candidates from settings', 'Domains', error);
}
// Fallback: panel host where user opened 3dp (often the target VPS in real usage).
const runtimeHost = window.location.hostname;
if (runtimeHost && !isLoopbackHost(runtimeHost)) {
return runtimeHost;
Logger.debug('Domains page: Checking runtime host as fallback', 'Domains', {
runtimeHost,
isLoopback: isLoopbackHost(runtimeHost)
});
// Если настройки пустые и мы на localhost — предлагаем localhost с предупреждением
// Это позволяет пользователю начать работу и затем изменить на правильный IP
if (runtimeHost) {
if (!isLoopbackHost(runtimeHost)) {
Logger.debug('Domains page: Using runtime host as address', 'Domains', runtimeHost);
return runtimeHost;
} else if (allowLoopbackFallback) {
// Явно разрешили localhost fallback
Logger.debug('Domains page: Using localhost fallback (explicit)', 'Domains', runtimeHost);
return runtimeHost;
} else if (settingsCandidates.length === 0) {
// Настройки пустые — используем localhost как единственный вариант
Logger.warn('Domains page: No settings configured, using localhost as temporary placeholder', 'Domains');
return runtimeHost;
}
}
// Optional fallback for explicit reset action: prefer some known address
// over keeping stale user input in the field.
// Last resort: first from settings even if loopback
if (allowLoopbackFallback) {
const anyFromSettings = settingsCandidates[0];
if (anyFromSettings) return anyFromSettings;
if (runtimeHost) return runtimeHost;
}
Logger.warn('Domains page: No address found anywhere', 'Domains');
return '';
};
}, [collectAddrCandidatesFromSettings, isLoopbackHost]);
const fetchScanStatus = async () => {
const fetchScanStatus = useCallback(async () => {
const { data } = await api.get('/domains/scan/status');
setScanStatus(data);
return data as ScanStatusResponse;
};
}, []);
const fetchLastScanResult = async (expectedRunId?: string | null) => {
const fetchLastScanResult = useCallback(async (expectedRunId?: string | null) => {
const { data } = await api.get('/domains/scan/last-result');
if (!data) return null;
if (expectedRunId && data.runId !== expectedRunId) return null;
@@ -149,22 +194,24 @@ export default function DomainsPage() {
setScanResult(data);
setScanCandidates(data.domains || []);
return data as ScanResponse;
};
}, []);
const loadDomains = async () => {
const loadDomains = useCallback(async () => {
try {
Logger.debug(`Loading page ${page + 1} (limit: ${rowsPerPage})`, 'Domains');
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
setDomains(data.data);
setTotalCount(data.total);
} catch (e) {
console.error(e);
Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains');
} catch (error) {
Logger.error('Failed to load', 'Domains', error);
}
};
}, [page, rowsPerPage]);
useEffect(() => {
loadDomains();
}, [page, rowsPerPage]);
}, [loadDomains]);
useEffect(() => {
const loadScannerContext = async () => {
@@ -181,53 +228,87 @@ export default function DomainsPage() {
setScanError('');
}
}
} catch (e) {
console.error(e);
}
try {
const defaultAddr = await resolveSuggestedScanAddr();
if (defaultAddr) {
// Do not overwrite manually saved value from localStorage.
setScanAddr((prev) => (prev.trim() ? prev : defaultAddr));
}
} catch (e) {
console.error(e);
} catch (error) {
Logger.error('Failed to load scanner context', 'Domains', error);
}
};
loadScannerContext();
}, []);
}, [fetchScanStatus]);
useEffect(() => {
// Hydrate scanner UI state once so users do not lose pre-import review list after reload.
try {
const raw = localStorage.getItem(SCAN_STORAGE_KEY);
if (!raw) return;
let restoredAddr: string | null = null;
const parsed = JSON.parse(raw) as {
scanAddr?: string;
scanSeconds?: number;
scanThread?: number;
scanTimeout?: number;
scanResult?: ScanResponse | null;
scanCandidates?: string[];
scanPanelExpanded?: boolean;
};
Logger.debug('Domains page: Starting hydrate', 'Domains', {
hasLocalStorage: !!raw,
localStorageValue: raw ? JSON.parse(raw).scanAddr : 'N/A'
});
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);
if (raw) {
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()) {
restoredAddr = parsed.scanAddr.trim();
setScanAddr(restoredAddr);
Logger.debug(`Domains page: Restored scanAddr from localStorage: "${restoredAddr}"`, 'Domains');
} else {
Logger.debug(`Domains page: scanAddr in localStorage is empty/whitespace, will fetch from settings`, 'Domains');
}
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);
} else {
Logger.debug('Domains page: No localStorage data found', 'Domains');
}
// Если scanAddr не был восстановлен (пустой localStorage ИЛИ пустое значение),
// пытаемся получить домен из настроек
if (!restoredAddr) {
Logger.debug('Domains page: Fetching suggested address from settings...', 'Domains');
// Пробуем сначала без localhost, если не найдём — разрешаем localhost fallback
resolveSuggestedScanAddr({ allowLoopbackFallback: false }).then((defaultAddr) => {
if (defaultAddr) {
setScanAddr(defaultAddr);
Logger.debug(`Domains page: Set scanAddr from settings: "${defaultAddr}"`, 'Domains');
} else {
// Пытаемся с localhost fallback если совсем ничего не найдено
Logger.debug('Domains page: Trying with localhost fallback...', 'Domains');
resolveSuggestedScanAddr({ allowLoopbackFallback: true }).then((fallbackAddr) => {
if (fallbackAddr) {
setScanAddr(fallbackAddr);
Logger.debug(`Domains page: Set scanAddr with localhost fallback: "${fallbackAddr}"`, 'Domains');
}
}).catch((error) => {
Logger.error('Failed to resolve suggested scan address (fallback)', 'Domains', error);
});
}
}).catch((error) => {
Logger.error('Failed to resolve suggested scan address', 'Domains', error);
});
} else {
Logger.debug(`Domains page: Using restored scanAddr: "${restoredAddr}"`, 'Domains');
}
} catch (error) {
Logger.error('Failed to hydrate scanner state from localStorage', 'Domains', error);
} finally {
setScanStateHydrated(true);
}
}, []);
}, [resolveSuggestedScanAddr]);
useEffect(() => {
if (!scanStateHydrated) return;
@@ -246,8 +327,8 @@ export default function DomainsPage() {
scanPanelExpanded,
}),
);
} catch (e) {
console.error(e);
} catch (error) {
Logger.error('Failed to persist scanner state to localStorage', 'Domains', error);
}
}, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]);
@@ -272,9 +353,9 @@ export default function DomainsPage() {
const runIdToLoad = activeScanRunId || status.lastRunId;
await fetchLastScanResult(runIdToLoad);
setActiveScanRunId(null);
} catch (e) {
} catch (error) {
if (!cancelled) {
console.error(e);
Logger.error('Failed to fetch scan status', 'Domains', error);
}
}
};
@@ -285,7 +366,7 @@ export default function DomainsPage() {
cancelled = true;
window.clearInterval(timer);
};
}, [isScanning, activeScanRunId]);
}, [isScanning, activeScanRunId, fetchScanStatus, fetchLastScanResult]);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
@@ -298,23 +379,37 @@ export default function DomainsPage() {
const handleAdd = async () => {
if (!newDomain) return;
Logger.debug(`Adding domain: ${newDomain}`, 'Domains');
await api.post('/domains', { name: newDomain });
Logger.debug(`Added domain: ${newDomain}`, 'Domains');
setNewDomain('');
loadDomains();
};
const handleDelete = async (id: number) => {
Logger.debug(`Deleting domain ID: ${id}`, 'Domains');
await api.delete(`/domains/${id}`);
Logger.debug(`Deleted domain ID: ${id}`, 'Domains');
loadDomains();
};
const handleDeleteAll = async () => {
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
setConfirmDialog({
open: true,
title: 'ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?',
onConfirm: async () => {
try {
Logger.debug('Deleting all domains', 'Domains');
await api.delete('/domains/all');
Logger.debug('All domains deleted', 'Domains');
loadDomains();
setSnackbar({ open: true, type: 'success', message: 'Все домены удалены' });
} catch {
Logger.error('Delete all failed', 'Domains');
setSnackbar({ open: true, type: 'error', message: 'Ошибка удаления' });
}
}
});
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -330,10 +425,10 @@ export default function DomainsPage() {
try {
const { data } = await api.post('/domains/upload', { domains: lines });
alert(`Успешно добавлено доменов: ${data.count}`);
setSnackbar({ open: true, type: 'success', message: `Успешно добавлено доменов: ${data.count}` });
loadDomains();
} catch (_err) {
alert('Ошибка при загрузке списка');
} catch {
setSnackbar({ open: true, type: 'error', message: 'Ошибка при загрузке списка' });
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
@@ -343,7 +438,7 @@ export default function DomainsPage() {
const handleStartScan = async () => {
if (!scanAddr.trim()) {
alert('Укажите IP/домен для сканирования');
setSnackbar({ open: true, type: 'error', message: 'Укажите IP/домен для сканирования' });
return;
}
@@ -353,6 +448,7 @@ export default function DomainsPage() {
let keepScanning = false;
try {
Logger.debug(`Starting scan: addr=${scanAddr.trim()}, seconds=${effectiveScanSeconds}, threads=${effectiveThread}, timeout=${effectiveTimeout}`, 'Scanner');
setIsScanning(true);
setScanError('');
setScanResult(null);
@@ -366,15 +462,18 @@ export default function DomainsPage() {
timeout: effectiveTimeout,
});
Logger.debug(`Scan started: runId=${data.runId}, found=${data.foundCount}`, 'Scanner');
setScanResult(data);
setScanCandidates(data.domains || []);
setActiveScanRunId(data.runId || null);
await fetchScanStatus();
} catch (e: any) {
const message = e?.response?.data?.message || e?.message || 'Ошибка запуска сканера';
setScanError(Array.isArray(message) ? message.join('; ') : message);
} catch (e) {
const message = getApiErrorMessage(e, 'Ошибка запуска сканера');
Logger.error(`Start error: ${message}`, 'Scanner');
setScanError(message);
if (e?.response?.status === 429) {
const status = getApiErrorStatus(e);
if (status === 429) {
try {
const status = await fetchScanStatus();
if (status.running) {
@@ -382,9 +481,10 @@ export default function DomainsPage() {
setIsScanning(true);
setActiveScanRunId(status.runId);
setScanError('Скан уже выполняется. Подключились к текущему запуску.');
Logger.debug('Connected to existing scan session', 'Scanner');
}
} catch (statusErr) {
console.error(statusErr);
Logger.error('Failed to fetch scan status on 429', 'Scanner', statusErr);
}
}
} finally {
@@ -400,11 +500,14 @@ export default function DomainsPage() {
if (found.length === 0) return;
try {
Logger.debug(`Importing ${found.length} scanned domains`, 'Domains');
const { data } = await api.post('/domains/upload', { domains: found });
alert(`Скан завершен. Добавлено новых доменов: ${data.count}`);
Logger.debug(`Imported ${data.count} new domains`, 'Domains');
setSnackbar({ open: true, type: 'success', message: `Скан завершен. Добавлено новых доменов: ${data.count}` });
loadDomains();
} catch (_e) {
alert('Ошибка импорта найденных доменов');
} catch {
Logger.error('Import failed', 'Domains');
setSnackbar({ open: true, type: 'error', message: 'Ошибка импорта найденных доменов' });
}
};
@@ -454,8 +557,8 @@ export default function DomainsPage() {
if (names.length === 0) return;
downloadDomainsAsTxt(`sni-whitelist-${getExportTimestamp()}.txt`, names);
} catch (_e) {
alert('Ошибка экспорта списка');
} catch {
setSnackbar({ open: true, type: 'error', message: 'Ошибка экспорта списка' });
}
};
@@ -723,6 +826,43 @@ export default function DomainsPage() {
/>
</Paper>
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.type}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение действия</DialogTitle>
<DialogContent>
<Typography>{confirmDialog.title}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>
Отмена
</Button>
<Button
onClick={() => {
setConfirmDialog({ ...confirmDialog, open: false });
confirmDialog.onConfirm();
}}
variant="contained"
color="error"
>
Удалить
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+14 -4
View File
@@ -3,6 +3,9 @@ import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/mat
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
import { Logger } from '../utils/logger';
import { getApiErrorMessage } from '../utils/errorHandlers';
import { APP_VERSION } from '../utils/version';
export default function LoginPage() {
const [creds, setCreds] = useState({ login: '', password: '' });
@@ -12,11 +15,18 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', { login: creds.login });
try {
const res = await api.post('/auth/login', creds);
login(res.data.access_token);
const token = res.data.access_token;
Logger.debug(`Success → token received, calling login()`, 'Login');
login(token);
navigate('/');
} catch (e) {
} catch (error) {
const message = getApiErrorMessage(error, 'Неверный логин или пароль');
Logger.error(`Error: ${message}`, 'Login');
setError('Неверный логин или пароль');
}
};
@@ -35,7 +45,7 @@ export default function LoginPage() {
animation: 'fadeIn 1.5s ease-out',
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
}}>
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label="v2.0.2" size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label={`v${APP_VERSION}`} size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
@@ -56,4 +66,4 @@ export default function LoginPage() {
</Paper>
</Box>
);
}
}
+148 -56
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery } from '@mui/material';
import React, { useEffect, useState, useCallback } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import api from '../api';
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
import { CheckCircle, PauseCircleFilled, PlayCircleFilled } from '@mui/icons-material';
import { Logger } from '../utils/logger';
const ROTATION_PRESETS = [
{ label: 'Сутки', value: 1440 },
@@ -25,23 +26,42 @@ export default function SettingsPage() {
});
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [intervalError, setIntervalError] = useState<string>('');
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => {
loadSettings();
const loadSettings = useCallback(async () => {
try {
Logger.debug('Loading settings...', 'Settings');
const { data } = await api.get('/settings');
Logger.debug('Settings API response', 'Settings', data);
setSettings((prev) => ({ ...prev, ...data }));
Logger.debug('Settings after update', 'Settings', {
rotation_interval: data.rotation_interval,
prev_interval: prev => prev.rotation_interval
});
if (data.admin_login) {
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
}
Logger.debug('Settings loaded successfully', 'Settings');
} catch (error) {
Logger.error('Failed to load', 'Settings', error);
}
}, []);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const getIntervalError = () => {
const val = parseInt(settings.rotation_interval, 10);
if (isNaN(val) || val < 10) {
setIntervalError('Минимальный интервал — 10 минут');
} else {
setIntervalError('');
return 'Минимальный интервал — 10 минут';
}
}, [settings.rotation_interval]);
return '';
};
const cleanData = () => {
const cleaned = { ...settings };
@@ -59,9 +79,10 @@ export default function SettingsPage() {
};
const handleCheckConnection = async () => {
const data = cleanData(); // Сначала чистим
const data = cleanData();
try {
Logger.debug(`Checking connection to: ${data.xui_url}`, 'Settings');
setMsg({ open: true, type: 'success', text: 'Проверка...' });
const res = await api.post('/settings/check', {
xui_url: data.xui_url,
@@ -70,38 +91,46 @@ export default function SettingsPage() {
});
if (res.data.success) {
setMsg({ open: true, type: 'success', text: 'Подключение успешно!' });
Logger.debug('Connection check: SUCCESS', 'Settings');
setMsg({
open: true,
type: 'success',
text: 'Подключение успешно!'
});
} else {
setMsg({ open: true, type: 'error', text: 'Ошибка: Неверные данные или нет доступа' });
Logger.warn('Connection check: FAILED', 'Settings', res.data);
setMsg({
open: true,
type: 'error',
text: 'Ошибка: Неверные данные или нет доступа'
});
}
} catch (e) {
} catch (error) {
Logger.error('Connection check error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сети при проверке' });
}
};
const loadSettings = async () => {
try {
const { data } = await api.get('/settings');
setSettings((prev) => ({ ...prev, ...data }));
if (data.admin_login) {
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
}
} catch (e) {
console.error(e);
}
};
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setSettings({ ...settings, [prop]: event.target.value });
};
const handleSettingChange = useCallback((prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setSettings(prev => ({ ...prev, [prop]: event.target.value }));
}, []);
const handlePresetClick = (minutes: number) => {
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
};
const handleSaveSettings = async () => {
if (intervalError) {
// Валидация полей подключения к 3x-ui
if (!settings.xui_url || !settings.xui_login || !settings.xui_password) {
setMsg({
open: true,
text: 'Заполните все поля подключения к 3x-ui (URL, логин, пароль)',
type: 'error'
});
return;
}
if (getIntervalError()) {
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
return;
}
@@ -109,61 +138,102 @@ export default function SettingsPage() {
const data = cleanData();
try {
Logger.debug('Saving settings', 'Settings', {
xui_url: data.xui_url ? '***' : 'empty',
xui_login: data.xui_login,
rotation_interval: data.rotation_interval
});
await api.post('/settings', data);
Logger.debug('Settings saved successfully', 'Settings');
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
} catch (e) {
} catch (error) {
Logger.error('Save error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
}
};
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
const handleSaveInterval = async () => {
if (getIntervalError()) {
setMsg({ open: true, text: 'Неверный интервал (минимум 10 минут)', type: 'error' });
return;
}
try {
Logger.debug('Saving rotation interval', 'Settings', {
rotation_interval: settings.rotation_interval
});
await api.post('/settings', {
rotation_interval: settings.rotation_interval
});
Logger.debug('Rotation interval saved successfully', 'Settings');
setMsg({ open: true, type: 'success', text: 'Интервал генерации применён!' });
} catch (error) {
Logger.error('Save interval error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения интервала' });
}
};
const handleAdminChange = useCallback((prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setAdminProfile(prev => ({ ...prev, [prop]: event.target.value }));
}, []);
const handleSaveAdmin = async () => {
try {
Logger.debug('Updating admin profile', 'Settings', { login: adminProfile.login });
await api.post('/auth/update-profile', adminProfile);
Logger.debug('Admin profile updated', 'Settings');
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
setAdminProfile(prev => ({ ...prev, password: '' }));
} catch (e) {
} catch (error) {
Logger.error('Update admin profile error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
}
};
const handleForceRotate = async () => {
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
try {
setLoadingRotate(true);
const res = await api.post('/rotation/rotate-all');
setConfirmDialog({
open: true,
title: 'ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?',
onConfirm: async () => {
try {
Logger.debug('Starting forced rotation', 'Rotation');
setLoadingRotate(true);
const res = await api.post('/rotation/rotate-all');
setLoadingRotate(false);
if (res.data && res.data.success) {
setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' });
} else {
setMsg({
open: true,
type: 'error',
text: res.data?.message || 'Ошибка выполнения ротации'
});
setLoadingRotate(false);
if (res.data && res.data.success) {
Logger.debug('Rotation completed successfully', 'Rotation');
setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' });
} else {
Logger.warn('Rotation completed with issues', 'Rotation', res.data?.message);
setMsg({
open: true,
type: 'error',
text: res.data?.message || 'Ошибка выполнения ротации'
});
}
} catch (error) {
setLoadingRotate(false);
Logger.error('Rotation error', 'Rotation', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' });
}
} catch (e) {
setLoadingRotate(false);
setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' });
}
}
});
};
const togglePause = async () => {
const newStatus = settings.rotation_status === 'active' ? 'stopped' : 'active';
const updatedSettings = { ...settings, rotation_status: newStatus };
Logger.debug(`Toggling rotation status: ${settings.rotation_status}${newStatus}`, 'Settings');
setSettings(updatedSettings);
try {
await api.post('/settings', updatedSettings);
} catch (e) {
setSettings((prev: any) => ({ ...prev, rotation_status: settings.rotation_status }));
Logger.debug('Rotation status updated', 'Settings');
} catch (error) {
Logger.error('Toggle pause error', 'Settings', error);
setSettings((prev) => ({ ...prev, rotation_status: prev.rotation_status }));
setMsg({ open: true, type: 'error', text: 'Не удалось изменить статус' });
}
};
@@ -316,7 +386,7 @@ export default function SettingsPage() {
/>
))}
</Stack>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveInterval}>
Применить интервал
</Button>
<Button
@@ -357,6 +427,28 @@ export default function SettingsPage() {
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
<Alert severity={msg.type}>{msg.text}</Alert>
</Snackbar>
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение действия</DialogTitle>
<DialogContent>
<Typography>{confirmDialog.title}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>
Отмена
</Button>
<Button
onClick={() => {
setConfirmDialog({ ...confirmDialog, open: false });
confirmDialog.onConfirm();
}}
variant="contained"
color="warning"
>
Продолжить
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+97 -30
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, FormControl, Select,
InputAdornment, InputLabel, MenuItem,
InputAdornment, InputLabel, MenuItem, Snackbar, Alert,
useTheme,
useMediaQuery,
Menu,
@@ -12,13 +12,14 @@ import {
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material';
import api from '../api';
import { Logger } from '../utils/logger';
interface Subscription {
id: string;
name: string;
uuid: string;
inbounds: any[];
inboundsConfig?: any[];
inbounds: unknown[];
inboundsConfig?: unknown[];
}
interface Tunnel {
@@ -61,7 +62,7 @@ const patchLink = function (link: string, newHost: string): string {
const newJsonStr = JSON.stringify(config);
const newBase64 = Buffer.from(newJsonStr).toString('base64');
return `vmess://${newBase64}`;
} catch (e) {
} catch {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
@@ -97,21 +98,36 @@ export default function SubscriptionsPage() {
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
// Snackbar state for notifications
const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' });
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => { loadSubs(); }, []);
const loadSubs = useCallback(async () => {
try {
Logger.debug('Loading subscriptions...', 'Subs');
const { data } = await api.get('/subscriptions');
setSubs(data);
Logger.debug(`Loaded ${data.length} subscriptions`, 'Subs');
const loadSubs = async () => {
const { data } = await api.get('/subscriptions');
setSubs(data);
const tunnelsRes = await api.get('/tunnels');
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
Logger.debug(`Loaded ${tunnelsRes.data.filter((el: Tunnel) => el.isInstalled).length} active tunnels`, 'Subs');
const tunnelsRes = await api.get('/tunnels');
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
const allDomains = await api.get('/domains/all');
setDomains(allDomains.data);
Logger.debug(`Loaded ${allDomains.data.length} domains`, 'Subs');
} catch (error) {
Logger.error('Failed to load', 'Subs', error);
throw error;
}
}, []);
const allDomains = await api.get('/domains/all');
setDomains(allDomains.data);
};
useEffect(() => { loadSubs(); }, [loadSubs]);
const handleActionMenuClick = (event: React.MouseEvent<HTMLButtonElement>, sub: Subscription) => {
setMenuAnchorEl(event.currentTarget);
@@ -201,11 +217,11 @@ export default function SubscriptionsPage() {
const handleSave = async () => {
if (Object.keys(portErrors).length > 0) {
alert('Пожалуйста, исправьте ошибки с портами');
setSnackbar({ open: true, type: 'error', message: 'Пожалуйста, исправьте ошибки с портами' });
return;
}
if (!name.trim()) {
alert('Введите имя подписки');
setSnackbar({ open: true, type: 'error', message: 'Введите имя подписки' });
return;
}
@@ -224,32 +240,46 @@ export default function SubscriptionsPage() {
};
try {
Logger.debug(`${editingId ? 'Updating' : 'Creating'} subscription`, 'Subs', payload);
if (editingId) {
await api.put(`/subscriptions/${editingId}`, payload);
Logger.debug(`Updated subscription ${editingId}`, 'Subs');
} else {
await api.post('/subscriptions', payload);
Logger.debug('Created subscription', 'Subs');
}
setOpen(false);
loadSubs();
} catch (error: any) {
alert(error.response?.data?.message || 'Произошла ошибка при сохранении');
setSnackbar({ open: true, type: 'success', message: editingId ? 'Подписка обновлена' : 'Подписка создана' });
} catch (error: unknown) {
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Произошла ошибка при сохранении';
Logger.error(`Save error: ${message}`, 'Subs');
setSnackbar({ open: true, type: 'error', message });
}
};
const handleDelete = async (id: string) => {
if (confirm('Удалить подписку и все соединения?')) {
await api.delete(`/subscriptions/${id}`);
loadSubs();
}
setConfirmDialog({
open: true,
title: 'Удалить подписку и все соединения?',
onConfirm: async () => {
Logger.debug(`Deleting subscription: ${id}`, 'Subs');
await api.delete(`/subscriptions/${id}`);
Logger.debug(`Deleted subscription ${id}`, 'Subs');
loadSubs();
setSnackbar({ open: true, type: 'success', message: 'Подписка удалена' });
}
});
};
const showLinks = (sub: Subscription) => {
let links = [];
let links: string[] = [];
if (selectedServer === 'main') {
links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
links = sub.inbounds?.map(i => (i as { link?: string }).link).filter(Boolean) || [];
} else {
const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip;
links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || [];
const tunnelIndex = +selectedServer - 1;
const host = tunnels[tunnelIndex]?.domain?.length > 0 ? tunnels[tunnelIndex].domain : tunnels[tunnelIndex].ip;
links = sub.inbounds?.map(i => patchLink((i as { link?: string }).link || '', host)).filter(Boolean) || [];
}
if (links.length === 0) {
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
@@ -311,14 +341,14 @@ export default function SubscriptionsPage() {
<>
<IconButton
color="primary"
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
onClick={() => navigator.clipboard.writeText(`${location.protocol}//${location.hostname}:${location.port}/bus/${sub.uuid}${selectedServer !== 'main' ? `/${selectedServer}` : ''}`)}
title="Копировать ссылку"
>
<ContentCopy />
</IconButton>
<IconButton
color="primary"
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
onClick={() => window.open(`${location.protocol}//${location.hostname}:${location.port}/bus/${sub.uuid}${selectedServer !== 'main' ? `/${selectedServer}` : ''}`, '_blank')}
title="Открыть подписку"
>
<OpenInNew />
@@ -346,13 +376,13 @@ export default function SubscriptionsPage() {
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{isMobile && activeSub && (
<MenuItem onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`)}>
<MenuItem onClick={() => navigator.clipboard.writeText(`${location.protocol}//${location.hostname}:${location.port}/bus/${activeSub.uuid}${selectedServer !== 'main' ? `/${selectedServer}` : ''}`)}>
<ListItemIcon><ContentCopy fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Копировать ссылку</ListItemText>
</MenuItem>
)}
{isMobile && activeSub && (
<MenuItem onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`, '_blank')}>
<MenuItem onClick={() => window.open(`${location.protocol}//${location.hostname}:${location.port}/bus/${activeSub.uuid}${selectedServer !== 'main' ? `/${selectedServer}` : ''}`, '_blank')}>
<ListItemIcon><OpenInNew fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Открыть подписку</ListItemText>
</MenuItem>
@@ -502,6 +532,43 @@ export default function SubscriptionsPage() {
<Button onClick={() => setLinksOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение</DialogTitle>
<DialogContent>
<Typography>{confirmDialog.title}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
<Button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog({ ...confirmDialog, open: false });
}}
variant="contained"
color="error"
>
Удалить
</Button>
</DialogActions>
</Dialog>
{/* Snackbar notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.type}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
+204 -40
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
@@ -8,10 +8,14 @@ import {
FormControl,
RadioGroup,
FormControlLabel,
Radio
Radio,
Snackbar,
Alert
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
import { getApiErrorMessage } from '../utils/errorHandlers';
import { Logger } from '../utils/logger';
interface Tunnel {
id: number;
@@ -34,54 +38,133 @@ export default function TunnelsPage() {
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
// Snackbar state for notifications
const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' });
const loadTunnels = async () => {
try {
const { data } = await api.get('/tunnels');
setTunnels(data);
} catch (e) { console.error(e); }
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
// Form validation errors
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const validateForm = () => {
const errors: Record<string, string> = {};
if (!form.name.trim()) {
errors.name = 'Введите название сервера';
}
if (!form.ip.trim()) {
errors.ip = 'Введите IP адрес';
} else {
// IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// IPv6 basic validation
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,7}:$|^([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}$|^([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}$|^([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}$|^([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})$|^:((:[0-9a-fA-F]{1,4}){1,7}|:)$/;
if (!ipv4Regex.test(form.ip) && !ipv6Regex.test(form.ip)) {
errors.ip = 'Неверный формат IP адреса';
}
}
if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) {
errors.sshPort = 'Порт должен быть от 1 до 65535';
}
if (!form.username.trim()) {
errors.username = 'Введите SSH пользователя';
}
if (authMethod === 'password' && !form.password) {
errors.password = 'Введите SSH пароль';
}
if (authMethod === 'key' && !form.privateKey.trim()) {
errors.privateKey = 'Введите SSH ключ';
} else if (authMethod === 'key' && !form.privateKey.includes('-----BEGIN')) {
errors.privateKey = 'Неверный формат SSH ключа';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const loadTunnels = useCallback(async () => {
try {
Logger.debug('Loading tunnels...', 'Tunnels');
const { data } = await api.get('/tunnels');
setTunnels(data);
Logger.debug(`Loaded ${data.length} tunnels`, 'Tunnels');
} catch (error) {
Logger.error('Failed to load', 'Tunnels', error);
}
}, []);
useEffect(() => { loadTunnels(); }, [loadTunnels]);
const handleCreate = async () => {
if (!validateForm()) {
setSnackbar({ open: true, type: 'error', message: 'Исправьте ошибки в форме' });
return;
}
const payload = {
...form,
password: authMethod === 'password' ? form.password : null,
privateKey: authMethod === 'key' ? form.privateKey : null,
};
Logger.debug(`Creating tunnel`, 'Tunnels', { name: form.name, ip: form.ip });
await api.post('/tunnels', payload);
Logger.debug('Tunnel created successfully', 'Tunnels');
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' });
setAuthMethod('password');
setFormErrors({});
loadTunnels();
setSnackbar({ open: true, type: 'success', message: 'Сервер добавлен' });
};
const handleDelete = async (id: number) => {
if (confirm('Удалить сервер из списка?')) {
await api.delete(`/tunnels/${id}`);
loadTunnels();
}
setConfirmDialog({
open: true,
title: 'Удалить сервер из списка?',
onConfirm: async () => {
Logger.debug(`Deleting tunnel ID: ${id}`, 'Tunnels');
await api.delete(`/tunnels/${id}`);
Logger.debug(`Deleted tunnel ID: ${id}`, 'Tunnels');
loadTunnels();
setSnackbar({ open: true, type: 'success', message: 'Сервер удалён' });
}
});
};
const handleInstall = async (id: number) => {
if (!confirm('Начать установку перенаправления на этот сервер?')) return;
setLoadingId(id);
try {
await api.post(`/tunnels/${id}/install`);
alert('Скрипт успешно установлен! Трафик перенаправляется.');
loadTunnels();
} catch (e: any) {
alert('Ошибка: ' + (e.response?.data?.message || e.message));
} finally {
setLoadingId(null);
}
setConfirmDialog({
open: true,
title: 'Начать установку перенаправления на этот сервер?',
onConfirm: async () => {
Logger.debug(`Installing forwarding on tunnel ID: ${id}`, 'Tunnels');
setLoadingId(id);
try {
await api.post(`/tunnels/${id}/install`);
Logger.debug('Forwarding installed successfully', 'Tunnels');
setSnackbar({ open: true, type: 'success', message: 'Скрипт успешно установлен! Трафик перенаправляется.' });
loadTunnels();
} catch (e) {
const message = getApiErrorMessage(e, 'Неизвестная ошибка');
Logger.error(`Install error on ID ${id}: ${message}`, 'Tunnels');
setSnackbar({ open: true, type: 'error', message: 'Ошибка: ' + message });
} finally {
setLoadingId(null);
}
}
});
};
const handleChange = (prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [prop]: e.target.value });
};
const handleChange = useCallback((prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm(prev => ({ ...prev, [prop]: e.target.value }));
}, []);
return (
<Box>
@@ -155,11 +238,44 @@ export default function TunnelsPage() {
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новый редирект сервер</DialogTitle>
<DialogContent>
<TextField margin="dense" label="Название" fullWidth value={form.name} onChange={handleChange('name')} />
<TextField margin="dense" label="IP адрес" fullWidth value={form.ip} onChange={handleChange('ip')} />
<TextField
margin="dense"
label="Название"
fullWidth
value={form.name}
onChange={handleChange('name')}
error={!!formErrors.name}
helperText={formErrors.name}
/>
<TextField
margin="dense"
label="IP адрес"
fullWidth
value={form.ip}
onChange={handleChange('ip')}
error={!!formErrors.ip}
helperText={formErrors.ip}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
<TextField
margin="dense"
label="SSH Порт"
type="number"
fullWidth
value={form.sshPort}
onChange={handleChange('sshPort')}
error={!!formErrors.sshPort}
helperText={formErrors.sshPort}
/>
<TextField
margin="dense"
label="SSH User"
fullWidth
value={form.username}
onChange={handleChange('username')}
error={!!formErrors.username}
helperText={formErrors.username}
/>
</Box>
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
@@ -169,18 +285,29 @@ export default function TunnelsPage() {
</FormControl>
{authMethod === 'password' ? (
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
<TextField
margin="dense"
label="SSH Пароль"
type="password"
fullWidth
value={form.password}
onChange={handleChange('password')}
error={!!formErrors.password}
helperText={formErrors.password}
/>
) : (
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
error={!!formErrors.privateKey}
helperText={formErrors.privateKey}
/>
)}
</DialogContent>
@@ -189,6 +316,43 @@ export default function TunnelsPage() {
<Button variant="contained" onClick={handleCreate}>Сохранить</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение</DialogTitle>
<DialogContent>
<Typography>{confirmDialog.title}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
<Button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog({ ...confirmDialog, open: false });
}}
variant="contained"
color="error"
>
Подтвердить
</Button>
</DialogActions>
</Dialog>
{/* Snackbar notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.type}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
+6
View File
@@ -0,0 +1,6 @@
export type AuthContextType = {
token: string | null;
isAuthenticated: boolean;
login: (token: string) => void;
logout: () => void;
};
+6
View File
@@ -0,0 +1,6 @@
export type ColorMode = 'light' | 'dark' | 'system';
export type ThemeContextType = {
mode: ColorMode;
toggleColorMode: () => void;
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Type guard to check if a value is an API error response
*/
export function isApiError(error: unknown): error is { response?: { status?: number; data?: { message?: string | string[] } } } {
return (
typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
);
}
/**
* Extract error message from API error response
*/
export function getApiErrorMessage(error: unknown, defaultMessage: string = 'Произошла ошибка'): string {
if (isApiError(error)) {
const data = error.response?.data;
const message = data?.message;
if (Array.isArray(message)) {
return message.join('; ');
}
if (typeof message === 'string') {
return message;
}
}
if (error instanceof Error) {
return error.message;
}
return defaultMessage;
}
/**
* Get HTTP status code from error response
*/
export function getApiErrorStatus(error: unknown): number | undefined {
if (isApiError(error)) {
return error.response?.status;
}
return undefined;
}
+57
View File
@@ -0,0 +1,57 @@
type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose';
const LOG_LEVELS: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
verbose: 4,
};
const getLogLevel = (): LogLevel => {
return (import.meta.env.VITE_LOG_LEVEL as LogLevel) || 'info';
};
const shouldLog = (level: LogLevel): boolean => {
const currentLevel = getLogLevel();
return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel];
};
const formatMessage = (module: string, message: string, data?: unknown): string => {
if (data !== undefined) {
return `[${module}] ${message} ${JSON.stringify(data)}`;
}
return `[${module}] ${message}`;
};
export const Logger = {
error: (message: string, module: string = 'App', data?: unknown) => {
if (shouldLog('error')) {
console.error(formatMessage(module, message), data || '');
}
},
warn: (message: string, module: string = 'App', data?: unknown) => {
if (shouldLog('warn')) {
console.warn(formatMessage(module, message), data || '');
}
},
info: (message: string, module: string = 'App', data?: unknown) => {
if (shouldLog('info')) {
console.info(formatMessage(module, message), data || '');
}
},
debug: (message: string, module: string = 'App', data?: unknown) => {
if (shouldLog('debug')) {
console.log(formatMessage(module, message), data || '');
}
},
verbose: (message: string, module: string = 'App', data?: unknown) => {
if (shouldLog('verbose')) {
console.log(formatMessage(module, message), data || '');
}
},
};
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = '2.1.2';