client reafactor

This commit is contained in:
Den Piligrim
2026-05-18 15:52:15 +03:00
parent 5629b43cd0
commit 962544820f
8 changed files with 918 additions and 762 deletions
+3 -1
View File
@@ -11,6 +11,7 @@ import NotFoundPage from './pages/NotFoundPage';
import { AxiosInterceptor } from './auth/AxiosInterceptor';
import PublicRoute from './auth/PublicRoute';
import TunnelsPage from './pages/TunnelsPage';
import NodesPage from './pages/NodesPage';
function App() {
return (
@@ -31,6 +32,7 @@ function App() {
<Route index element={<SubscriptionsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="domains" element={<DomainsPage />} />
<Route path="nodes" element={<NodesPage />} />
<Route path="tunnels" element={<TunnelsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
@@ -41,4 +43,4 @@ function App() {
);
}
export default App;
export default App;
+29 -21
View File
@@ -1,9 +1,17 @@
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
Box,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
useMediaQuery,
useTheme,
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { Dns, Hub, People, Settings, SwapHoriz } from '@mui/icons-material';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import Header from './Header';
@@ -13,6 +21,14 @@ import { useSecureConnection } from '../utils/useSecureConnection';
const drawerWidth = 240;
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
{ text: 'Домены', icon: <Dns />, path: '/domains' },
{ text: 'Ноды', icon: <Hub />, path: '/nodes' },
{ text: 'Relay серверы', icon: <SwapHoriz />, path: '/tunnels' },
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
@@ -22,23 +38,16 @@ export default function Layout() {
const { isSecure } = useSecureConnection();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
setMobileOpen((prev) => !prev);
};
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
{ text: 'Домены', icon: <Dns />, path: '/domains' },
{ text: 'Перенаправление', icon: <SwapHoriz />, path: '/tunnels' },
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
const drawerContent = (
<Box sx={{ overflow: 'auto' }}>
<Toolbar />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
<ListItemButton
selected={location.pathname === item.path}
onClick={() => {
navigate(item.path);
@@ -56,11 +65,10 @@ export default function Layout() {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
{/* Передаем функцию открытия в Header */}
<Header onMenuClick={handleDrawerToggle} isMobile={isMobile} />
<Drawer
variant={isMobile ? "temporary" : "permanent"}
variant={isMobile ? 'temporary' : 'permanent'}
open={isMobile ? mobileOpen : true}
onClose={handleDrawerToggle}
sx={{
@@ -72,15 +80,15 @@ export default function Layout() {
{drawerContent}
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%',
overflowX: 'hidden'
overflowX: 'hidden',
}}
>
<Toolbar />
@@ -92,4 +100,4 @@ export default function Layout() {
</Box>
</Box>
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import api from '../../api';
import type { NodePayload, NodeRecord } from '../../types/node';
export const nodesApi = {
async list() {
const { data } = await api.get<NodeRecord[]>('/nodes');
return data;
},
async create(payload: NodePayload) {
const { data } = await api.post<NodeRecord>('/nodes', payload);
return data;
},
async update(id: string, payload: Partial<NodePayload>) {
const { data } = await api.put<NodeRecord>(`/nodes/${id}`, payload);
return data;
},
async remove(id: string) {
const { data } = await api.delete<{ success: boolean }>(`/nodes/${id}`);
return data;
},
async setMain(id: string) {
const { data } = await api.post<NodeRecord>(`/nodes/${id}/main`);
return data;
},
async check(id: string) {
const { data } = await api.post<{ success: boolean; version?: string }>(
`/nodes/${id}/check`,
);
return data;
},
async checkPayload(payload: NodePayload) {
const { data } = await api.post<{ success: boolean; version?: string }>(
'/nodes/check',
payload,
);
return data;
},
async syncFromMain() {
const { data } = await api.post<{ success: boolean; count: number }>(
'/nodes/sync/main',
);
return data;
},
};
+300
View File
@@ -0,0 +1,300 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
Stack,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import {
Add,
CheckCircle,
Delete,
Edit,
Refresh,
Star,
StarBorder,
Sync,
} from '@mui/icons-material';
import { nodesApi } from '../features/nodes/api';
import type { NodeAuthType, NodePayload, NodeRecord } from '../types/node';
const emptyForm: NodePayload = {
name: '',
url: '',
authType: 'password',
login: '',
password: '',
token: '',
isMain: false,
};
export default function NodesPage() {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<NodeRecord | null>(null);
const [form, setForm] = useState<NodePayload>(emptyForm);
const [checkingId, setCheckingId] = useState<string | null>(null);
const [message, setMessage] = useState({
open: false,
type: 'success' as 'success' | 'error',
text: '',
});
const mainNode = useMemo(() => nodes.find((node) => node.isMain), [nodes]);
const loadNodes = useCallback(async () => {
setNodes(await nodesApi.list());
}, []);
useEffect(() => {
loadNodes();
}, [loadNodes]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setOpen(true);
};
const openEdit = (node: NodeRecord) => {
setEditing(node);
setForm({
name: node.name,
url: node.url || '',
authType: node.authType,
login: node.login || '',
password: '',
token: '',
isMain: node.isMain,
version: node.version || '',
});
setOpen(true);
};
const updateField = <K extends keyof NodePayload>(key: K, value: NodePayload[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const saveNode = async () => {
if (!form.name.trim() || !form.url.trim()) {
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
return;
}
const payload: NodePayload = {
...form,
url: form.url.replace(/\/+$/, ''),
login: form.authType === 'password' ? form.login : undefined,
password: form.authType === 'password' && form.password ? form.password : undefined,
token: form.authType === 'token' && form.token ? form.token : undefined,
};
if (editing) {
await nodesApi.update(editing.id, payload);
} else {
await nodesApi.create(payload);
}
setOpen(false);
setMessage({
open: true,
type: 'success',
text: editing ? 'Нода обновлена' : 'Нода добавлена',
});
loadNodes();
};
const checkFormConnection = async () => {
if (!form.name.trim() || !form.url.trim()) {
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
return;
}
const result = await nodesApi.checkPayload({
...form,
url: form.url.replace(/\/+$/, ''),
login: form.authType === 'password' ? form.login : undefined,
password: form.authType === 'password' ? form.password : undefined,
token: form.authType === 'token' ? form.token : undefined,
});
setMessage({
open: true,
type: result.success ? 'success' : 'error',
text: result.success ? 'Подключение успешно' : 'Не удалось подключиться',
});
};
const checkNode = async (node: NodeRecord) => {
setCheckingId(node.id);
try {
const result = await nodesApi.check(node.id);
setMessage({
open: true,
type: result.success ? 'success' : 'error',
text: result.success ? 'Подключение успешно' : 'Не удалось подключиться',
});
loadNodes();
} finally {
setCheckingId(null);
}
};
const syncNodes = async () => {
const result = await nodesApi.syncFromMain();
setMessage({
open: true,
type: 'success',
text: `Синхронизировано нод: ${result.count}`,
});
loadNodes();
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, gap: 2 }}>
<Box>
<Typography variant="h4">Ноды</Typography>
</Box>
<Box>
<Stack direction="row" spacing={1}>
<Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
Синхронизировать
</Button>
<Button startIcon={<Add />} variant="contained" onClick={openCreate}>
Добавить
</Button>
</Stack>
</Box>
</Box>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>URL панели</TableCell>
<TableCell>Авторизация</TableCell>
<TableCell>Статус</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id}>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<IconButton size="small" onClick={() => nodesApi.setMain(node.id).then(loadNodes)} title={node.isMain ? '' : 'Сделать основной'}>
{node.isMain ? <Star color="warning" /> : <StarBorder />}
</IconButton>
<Typography fontWeight={700}>{node.name}</Typography>
</Stack>
</TableCell>
<TableCell>
{node.url}
</TableCell>
<TableCell>{node.authType}</TableCell>
<TableCell>
{node.isMain && <Chip icon={<CheckCircle />} label="Основная" color="success" size="small" />}
</TableCell>
<TableCell align="right">
<IconButton onClick={() => openEdit(node)}>
<Edit />
</IconButton>
<IconButton color="error" onClick={() => nodesApi.remove(node.id).then(loadNodes)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
{nodes.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary' }}>
Ноды не добавлены
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? 'Редактировать ноду' : 'Новая нода'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Название" value={form.name} onChange={(e) => updateField('name', e.target.value)} />
<TextField
label="URL панели 3x-ui"
helperText="Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH"
value={form.url}
onChange={(e) => updateField('url', e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип авторизации</InputLabel>
<Select
value={form.authType}
label="Тип авторизации"
onChange={(e) => updateField('authType', e.target.value as NodeAuthType)}
>
<MenuItem value="password">Логин и пароль</MenuItem>
<MenuItem value="token">Токен</MenuItem>
</Select>
</FormControl>
{form.authType === 'password' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField label="Логин" fullWidth value={form.login || ''} onChange={(e) => updateField('login', e.target.value)} />
<TextField
label={editing ? 'Новый пароль' : 'Пароль'}
type="password"
fullWidth
value={form.password || ''}
onChange={(e) => updateField('password', e.target.value)}
/>
</Stack>
) : (
<TextField
label={editing ? 'Новый токен' : 'Токен'}
type="password"
value={form.token || ''}
onChange={(e) => updateField('token', e.target.value)}
/>
)}
<Stack direction="row" alignItems="center" spacing={1}>
<Switch checked={!!form.isMain} onChange={(e) => updateField('isMain', e.target.checked)} />
<Typography>Сделать основной нодой</Typography>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={checkFormConnection}>Проверить подключение</Button>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button variant="contained" onClick={saveNode}>Сохранить</Button>
</DialogActions>
</Dialog>
<Snackbar open={message.open} autoHideDuration={5000} onClose={() => setMessage({ ...message, open: false })}>
<Alert severity={message.type}>{message.text}</Alert>
</Snackbar>
</Box>
);
}
+66 -576
View File
@@ -1,615 +1,105 @@
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, List, ListItem, FormControlLabel, Checkbox } from '@mui/material';
import { type ChangeEvent, useCallback, useEffect, useState } from 'react';
import {
Alert,
Box,
Button,
Divider,
Paper,
Snackbar,
Stack,
TextField,
Typography,
} from '@mui/material';
import api from '../api';
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Refresh } from '@mui/icons-material';
import { Logger } from '../utils/logger';
const ROTATION_PRESETS = [
{ label: 'Сутки', value: 1440 },
{ label: '3 дня', value: 4320 },
{ label: 'Неделя', value: 10080 },
];
interface Subscription {
id: string;
name: string;
uuid: string;
isAutoRotationEnabled?: boolean;
}
export default function SettingsPage() {
const [settings, setSettings] = useState({
xui_url: '',
xui_login: '',
xui_password: '',
rotation_interval: '30',
rotation_status: 'active',
last_rotation_timestamp: '',
});
const [adminProfile, setAdminProfile] = useState({
login: '',
password: '',
});
const [subs, setSubs] = useState<Subscription[]>([]);
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
const [confirmDialog, setConfirmDialog] = useState({
open: false, title: '', onConfirm: () => {},
confirmText: 'Удалить', confirmColor: 'error' as 'error' | 'primary'
const [message, setMessage] = useState({
open: false,
type: 'success' as 'success' | 'error',
text: '',
});
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const loadSettings = useCallback(async () => {
const loadProfile = 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
});
const { data } = await api.get<Record<string, string>>('/settings');
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);
}
}, []);
const loadSubscriptions = useCallback(async () => {
try {
Logger.debug('Loading subscriptions...', 'Settings');
const { data } = await api.get('/subscriptions');
setSubs(data);
Logger.debug(`Loaded ${data.length} subscriptions`, 'Settings');
} catch (error) {
Logger.error('Failed to load subscriptions', 'Settings', error);
Logger.error('Failed to load profile settings', 'Settings', error);
}
}, []);
useEffect(() => {
loadSettings();
loadSubscriptions();
}, [loadSettings, loadSubscriptions]);
loadProfile();
}, [loadProfile]);
const getIntervalError = () => {
const val = parseInt(settings.rotation_interval, 10);
if (isNaN(val) || val < 10) {
return 'Минимальный интервал — 10 минут';
}
return '';
};
const handleChange =
(field: 'login' | 'password') =>
(event: ChangeEvent<HTMLInputElement>) => {
setAdminProfile((prev) => ({ ...prev, [field]: event.target.value }));
};
const cleanData = () => {
const cleaned = { ...settings };
if (cleaned.xui_url) {
cleaned.xui_url = cleaned.xui_url.replace(/\/+$/, '');
}
if (cleaned.xui_login) cleaned.xui_login = cleaned.xui_login.trim();
if (cleaned.xui_password) cleaned.xui_password = cleaned.xui_password.trim();
setSettings(prev => ({ ...prev, ...cleaned }));
return cleaned;
};
const handleCheckConnection = async () => {
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,
xui_login: data.xui_login,
xui_password: data.xui_password
});
if (res.data.success) {
Logger.debug('Connection check: SUCCESS', 'Settings');
setMsg({
open: true,
type: 'success',
text: 'Подключение успешно!'
});
} else {
Logger.warn('Connection check: FAILED', 'Settings', res.data);
setMsg({
open: true,
type: 'error',
text: 'Ошибка: Неверные данные или нет доступа'
});
}
} catch (error) {
Logger.error('Connection check error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сети при проверке' });
}
};
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 () => {
// Валидация полей подключения к 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;
}
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 (error) {
Logger.error('Save error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
}
};
const handleSaveInterval = async () => {
if (getIntervalError()) {
setMsg({ open: true, text: 'Неверный интервал (минимум 10 минут)', type: 'error' });
const handleSave = async () => {
if (!adminProfile.login.trim()) {
setMessage({ open: true, type: 'error', text: 'Login is required' });
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: '' }));
setAdminProfile((prev) => ({ ...prev, password: '' }));
setMessage({ open: true, type: 'success', text: 'Profile updated' });
} catch (error) {
Logger.error('Update admin profile error', 'Settings', error);
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
Logger.error('Update profile error', 'Settings', error);
setMessage({ open: true, type: 'error', text: 'Failed to update profile' });
}
};
const handleForceRotate = async () => {
setConfirmDialog({
open: true,
title: 'ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?',
confirmText: 'Сгенерировать',
confirmColor: 'primary',
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) {
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: 'Ошибка сети или сервера' });
}
}
});
};
const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => {
try {
await api.put('/subscriptions/bulk-auto-rotation', {
subscriptionIds: [subscriptionId],
enabled
});
setSubs(prev => prev.map(s =>
s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s
));
setMsg({
open: true,
type: 'success',
text: enabled ? 'Авторотация включена' : 'Авторотация выключена'
});
} catch (error: unknown) {
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления';
Logger.error(`Toggle auto-rotation error: ${message}`, 'Settings');
setMsg({ open: true, type: 'error', text: message });
loadSubscriptions();
}
};
const handleManualRotate = async (sub: Subscription) => {
setConfirmDialog({
open: true,
title: `Обновить подписку "${sub.name}" сейчас?`,
confirmText: 'Обновить',
confirmColor: 'primary',
onConfirm: async () => {
try {
Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Settings');
const res = await api.post(`/rotation/rotate-one/${sub.id}`);
Logger.debug('Manual rotation completed', 'Settings');
setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация выполнена' });
loadSubscriptions();
} catch (error: unknown) {
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации';
Logger.error(`Manual rotation error: ${message}`, 'Settings');
setMsg({ open: true, type: 'error', text: message });
}
}
});
};
const handleBulkUpdate = async (enabled: boolean) => {
try {
const { data } = await api.put('/subscriptions/bulk-auto-rotation', {
subscriptionIds: subs.map(s => s.id),
enabled
});
setMsg({ open: true, type: 'success', text: data.message || 'Настройки обновлены' });
loadSubscriptions();
} catch (error: unknown) {
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления';
Logger.error(`Bulk update error: ${message}`, 'Settings');
setMsg({ open: true, type: 'error', text: message });
}
};
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);
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: 'Не удалось изменить статус' });
}
};
const formatDate = (isoString: string) => {
if (!isoString) return 'Нет данных';
return new Date(+isoString).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
};
const getNextRotationDate = () => {
if (settings.rotation_status === 'stopped') return 'Пауза';
if (!settings.last_rotation_timestamp) return 'Ожидание...';
const last = new Date(+settings.last_rotation_timestamp);
const intervalMinutes = parseInt(settings.rotation_interval) || 60;
const next = new Date(last.getTime() + intervalMinutes * 60000);
return next.toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
};
const isPaused = settings.rotation_status === 'stopped';
return (
<Box>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Настройки утилиты</Typography>
<Typography variant="h4" gutterBottom>
Настройки
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12 }}>
<Grid container spacing={1}>
<Grid size={{ xs: 12, md: 4 }}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
Статус сервиса
</Typography>
{isPaused ?
<Chip icon={<PauseCircleFilled />} label="Остановлен" color="warning" size="small" variant="outlined" /> :
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" />
}
<Tooltip title={isPaused ? "Возобновить ротацию" : "Поставить на паузу"}>
<IconButton
onClick={togglePause}
size="small"
sx={{
bgcolor: 'background.paper',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
ml: 1
}}
>
{isPaused ? <PlayCircleFilled fontSize="large" /> : <PauseCircleFilled fontSize="large" />}
</IconButton>
</Tooltip>
</Grid>
{/* Последняя генерация */}
<Grid size={{ xs: 12, md: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<Box>
<Typography variant="subtitle2" color="textSecondary">
Последняя генерация
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
{formatDate(settings.last_rotation_timestamp)}
</Typography>
</Box>
</Stack>
</Grid>
{/* Следующая генерация */}
<Grid size={{ xs: 12, md: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<Box>
<Typography variant="subtitle2" color="textSecondary">
Следующая генерация
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
{getNextRotationDate()}
</Typography>
</Box>
</Stack>
</Grid>
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="URL панели"
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
helperText="Например: https://my-vpn.com:2053/wfgpoVHaOF"
/>
<TextField
fullWidth margin="normal" label="Логин 3x-ui"
value={settings.xui_login} onChange={handleSettingChange('xui_login')}
/>
<TextField
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
/>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Сохранить подключение
<Paper sx={{ p: 3, maxWidth: 680 }}>
<Typography variant="h6">Профиль панели 3dp-manager</Typography>
<Divider sx={{ my: 2 }} />
<Stack spacing={2}>
<TextField
label="Логин"
value={adminProfile.login}
onChange={handleChange('login')}
fullWidth
/>
<TextField
label="Новый пароль"
type="password"
value={adminProfile.password}
onChange={handleChange('password')}
helperText="Оставьте пустым, если не хотите менять пароль"
fullWidth
/>
<Box>
<Button variant="contained" onClick={handleSave}>
Сохранить
</Button>
{settings.xui_url && settings.xui_login && settings.xui_password && (
<Button
variant="outlined"
color="info"
sx={{ mt: 2, ml: isMobile ? 1 : 2 }}
onClick={handleCheckConnection}
>
Проверить
</Button>
)}
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Интервал генерации"
type="number"
value={settings.rotation_interval}
onChange={handleSettingChange('rotation_interval')}
slotProps={{
input: { endAdornment: <InputAdornment position="end">мин</InputAdornment> }
}}
helperText="Как часто менять инбаунды (минимум 10 мин)"
/>
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2 }}>
{ROTATION_PRESETS.map((preset) => (
<Chip
key={preset.value}
label={preset.label}
onClick={() => handlePresetClick(preset.value)}
color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"}
variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"}
clickable
/>
))}
</Stack>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveInterval}>
Применить интервал
</Button>
<Button
variant="outlined"
loading={loadingRotate}
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: isMobile ? 0 : 2 }}
>
Сгенерировать сейчас
</Button>
<Divider sx={{ my: 3 }} />
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
Управление авторотацией подписок
</Typography>
<Typography variant="body2" color="textSecondary" paragraph>
Выберите подписки для автоматической ротации:
</Typography>
{subs.length === 0 ? (
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Нет активных подписок
</Typography>
) : (
<List sx={{ maxHeight: 400, overflow: 'auto', bgcolor: 'background.default', borderRadius: 1 }}>
{subs.map(sub => (
<ListItem
key={sub.id}
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': { borderBottom: 'none' }
}}
>
<FormControlLabel
control={
<Checkbox
checked={sub.isAutoRotationEnabled ?? true}
onChange={(e) => handleToggleAutoRotation(sub.id, e.target.checked)}
color="primary"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{sub.name}</Typography>
<Typography variant="caption" color="textSecondary">
{sub.uuid.substring(0, 8)}...
</Typography>
</Box>
}
sx={{ flexGrow: 1 }}
/>
<Tooltip title="Обновить подписку вручную">
<IconButton
size="small"
onClick={() => handleManualRotate(sub)}
color="primary"
>
<Refresh />
</IconButton>
</Tooltip>
</ListItem>
))}
</List>
)}
{subs.length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={() => handleBulkUpdate(true)}
>
Включить для всех
</Button>
<Button
variant="outlined"
size="small"
onClick={() => handleBulkUpdate(false)}
>
Выключить для всех
</Button>
</Box>
)}
</Paper>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Логин администратора"
value={adminProfile.login}
onChange={handleAdminChange('login')}
/>
<TextField
fullWidth margin="normal" label="Новый пароль" type="password"
value={adminProfile.password}
onChange={handleAdminChange('password')}
helperText="Оставьте пустым, если не хотите менять"
/>
<Button variant="contained" color="warning" sx={{ mt: 2 }} onClick={handleSaveAdmin}>
Обновить профиль
</Button>
</Paper>
</Box>
</Grid>
</Grid>
</Stack>
</Paper>
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
<Alert severity={msg.type}>{msg.text}</Alert>
<Snackbar
open={message.open}
autoHideDuration={5000}
onClose={() => setMessage({ ...message, open: false })}
>
<Alert severity={message.type}>{message.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={confirmDialog.confirmColor}
>
{confirmDialog.confirmText}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}
+229 -9
View File
@@ -9,20 +9,27 @@ import {
Menu,
ListItemIcon,
ListItemText,
Checkbox
Checkbox,
Stack,
Chip,
Divider,
Tooltip
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove, Refresh } from '@mui/icons-material';
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove, Refresh, PauseCircleFilled, PlayCircleFilled } from '@mui/icons-material';
import api from '../api';
import { copyToClipboard } from '../utils/copyToClipboard';
import { Logger } from '../utils/logger';
import type { NodeRecord } from '../types/node';
interface Subscription {
id: string;
name: string;
uuid: string;
inbounds: unknown[];
inboundsConfig?: unknown[];
inboundsConfig?: InboundConfigUI[];
isAutoRotationEnabled?: boolean;
nodeId?: string;
relayServerId?: number;
}
interface Tunnel {
@@ -31,6 +38,7 @@ interface Tunnel {
ip: string;
domain: string;
isInstalled: boolean;
nodeId?: string;
}
interface InboundConfigUI {
@@ -39,6 +47,8 @@ interface InboundConfigUI {
port: string;
sni: string;
link?: string;
nodeId?: string;
relayServerId?: string;
}
interface Domain { id: number; name: string; }
@@ -95,10 +105,17 @@ const getSubscriptionUrl = (uuid: string, tunnelId: string | number) => {
export default function SubscriptionsPage() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [selectedServer, setSelectedServer] = useState<string | number>('main');
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeSub, setActiveSub] = useState<Subscription | null>(null);
const [domains, setDomains] = useState<Domain[]>([]);
const [rotationSettings, setRotationSettings] = useState({
rotation_interval: '30',
rotation_status: 'active',
last_rotation_timestamp: '',
});
const [rotationLoading, setRotationLoading] = useState(false);
const openActionMenu = Boolean(menuAnchorEl);
// Состояния модального окна конструктора
@@ -135,9 +152,16 @@ export default function SubscriptionsPage() {
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
Logger.debug(`Loaded ${tunnelsRes.data.filter((el: Tunnel) => el.isInstalled).length} active tunnels`, 'Subs');
const nodesRes = await api.get<NodeRecord[]>('/nodes');
setNodes(nodesRes.data);
Logger.debug(`Loaded ${nodesRes.data.length} nodes`, 'Subs');
const allDomains = await api.get('/domains/all');
setDomains(allDomains.data);
Logger.debug(`Loaded ${allDomains.data.length} domains`, 'Subs');
const settingsRes = await api.get('/settings');
setRotationSettings((prev) => ({ ...prev, ...settingsRes.data }));
} catch (error) {
Logger.error('Failed to load', 'Subs', error);
throw error;
@@ -185,7 +209,9 @@ export default function SubscriptionsPage() {
type: i.type || 'vless-tcp-reality',
port: i.port ? i.port.toString() : 'random',
sni: i.sni || 'random',
link: i.link || ''
link: i.link || '',
nodeId: i.nodeId || '',
relayServerId: i.relayServerId ? i.relayServerId.toString() : ''
})));
} else {
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
@@ -196,7 +222,18 @@ export default function SubscriptionsPage() {
};
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
setInbounds(prev => prev.map(inb => {
if (inb.id !== id) return inb;
const next = { ...inb, [field]: value };
if (field === 'nodeId') {
next.relayServerId = '';
}
if (field === 'type' && value === 'custom') {
next.nodeId = '';
next.relayServerId = '';
}
return next;
}));
if (field === 'port' || (field === 'type' && value === 'custom')) {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
}
@@ -246,12 +283,17 @@ export default function SubscriptionsPage() {
name,
inboundsConfig: inbounds.map(i => {
if (i.type === 'custom') {
return { type: i.type, link: i.link };
return {
type: i.type,
link: i.link,
};
}
return {
type: i.type,
port: i.port === 'random' ? 'random' : parseInt(i.port),
sni: i.sni
sni: i.sni,
nodeId: i.nodeId || undefined,
relayServerId: i.relayServerId ? parseInt(i.relayServerId, 10) : undefined
};
})
};
@@ -335,6 +377,95 @@ export default function SubscriptionsPage() {
});
};
const saveRotationSettings = async (nextSettings = rotationSettings) => {
await api.post('/settings', nextSettings);
setRotationSettings(nextSettings);
};
const toggleRotationService = async () => {
const nextStatus = rotationSettings.rotation_status === 'stopped' ? 'active' : 'stopped';
const nextSettings = { ...rotationSettings, rotation_status: nextStatus };
try {
await saveRotationSettings(nextSettings);
setSnackbar({
open: true,
type: 'success',
message: nextStatus === 'active' ? 'Ротация включена' : 'Ротация остановлена'
});
} catch (error) {
Logger.error('Rotation status update error', 'Subs', error);
setSnackbar({ open: true, type: 'error', message: 'Не удалось изменить статус ротации' });
}
};
const saveRotationInterval = async () => {
const interval = parseInt(rotationSettings.rotation_interval, 10);
if (Number.isNaN(interval) || interval < 10) {
setSnackbar({ open: true, type: 'error', message: 'Минимальный интервал ротации — 10 минут' });
return;
}
try {
await saveRotationSettings(rotationSettings);
setSnackbar({ open: true, type: 'success', message: 'Интервал ротации сохранён' });
} catch (error) {
Logger.error('Rotation interval update error', 'Subs', error);
setSnackbar({ open: true, type: 'error', message: 'Не удалось сохранить интервал' });
}
};
const rotateAllNow = async () => {
setConfirmDialog({
open: true,
title: 'Сгенерировать инбаунды сейчас для всех активных подписок?',
confirmText: 'Сгенерировать',
confirmColor: 'primary',
onConfirm: async () => {
try {
setRotationLoading(true);
const { data } = await api.post('/rotation/rotate-all');
setSnackbar({
open: true,
type: data?.success ? 'success' : 'error',
message: data?.message || 'Ротация завершена'
});
loadSubs();
} catch (error: unknown) {
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации';
setSnackbar({ open: true, type: 'error', message });
} finally {
setRotationLoading(false);
}
}
});
};
const formatRotationDate = (value: string) => {
if (!value) return 'Нет данных';
return new Date(Number(value)).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getNextRotationDate = () => {
if (rotationSettings.rotation_status === 'stopped') return 'Пауза';
if (!rotationSettings.last_rotation_timestamp) return 'Ожидание';
const interval = parseInt(rotationSettings.rotation_interval, 10) || 30;
const next = new Date(Number(rotationSettings.last_rotation_timestamp) + interval * 60000);
return next.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const showLinks = (sub: Subscription) => {
let links: string[] = [];
if (selectedServer === 'main') {
@@ -357,6 +488,13 @@ export default function SubscriptionsPage() {
setSnackbar({ open: true, type: 'success', message: 'Ссылка на подписку скопирована' });
};
const getDefaultNodeId = () => nodes.find((node) => node.isMain)?.id || '';
const getRelayOptions = (inboundNodeId?: string) => {
const effectiveNodeId = inboundNodeId || getDefaultNodeId();
return tunnels.filter((tunnel) => tunnel.nodeId === effectiveNodeId);
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
@@ -388,6 +526,56 @@ export default function SubscriptionsPage() {
</Box>
</Box>
<Paper sx={{ p: 2, mb: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems={{ xs: 'stretch', md: 'center' }}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Статус ротации</Typography>
<Chip
icon={rotationSettings.rotation_status === 'stopped' ? <PauseCircleFilled /> : <PlayCircleFilled />}
label={rotationSettings.rotation_status === 'stopped' ? 'Остановлена' : 'Активна'}
color={rotationSettings.rotation_status === 'stopped' ? 'warning' : 'success'}
size="small"
variant="outlined"
sx={{ mt: 1 }}
/>
</Box>
<Box>
<Tooltip title={rotationSettings.rotation_status === 'stopped' ? "Возобновить ротацию" : "Поставить на паузу"}>
<IconButton
onClick={toggleRotationService}
size="small"
>
{rotationSettings.rotation_status === 'stopped' ? <PlayCircleFilled fontSize="large" /> : <PauseCircleFilled fontSize="large" />}
</IconButton>
</Tooltip>
</Box>
<Divider flexItem orientation={isMobile ? 'horizontal' : 'vertical'} />
<Box>
<Typography variant="subtitle2" color="text.secondary">Последняя генерация</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>{formatRotationDate(rotationSettings.last_rotation_timestamp)}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Следующая генерация</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>{getNextRotationDate()}</Typography>
</Box>
<TextField
label="Интервал, мин"
type="number"
size="small"
value={rotationSettings.rotation_interval}
onChange={(e) => setRotationSettings((prev) => ({ ...prev, rotation_interval: e.target.value }))}
sx={{ width: { xs: '100%', md: 150 } }}
/>
<Box sx={{ flexGrow: 1 }} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<Button variant="outlined" onClick={saveRotationInterval}>Сохранить интервал</Button>
<Button variant="contained" loading={rotationLoading} onClick={rotateAllNow}>
Обновить все
</Button>
</Stack>
</Stack>
</Paper>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
@@ -497,7 +685,7 @@ export default function SubscriptionsPage() {
<TextField
autoFocus margin="dense" label="Имя подписки" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
sx={{ mb: 4 }}
sx={{ mb: 2 }}
/>
<Typography variant="h6" sx={{ mb: 2 }}>
@@ -534,6 +722,38 @@ export default function SubscriptionsPage() {
</FormControl>
) : (
<>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel>Нода</InputLabel>
<Select
value={inb.nodeId || ''}
label="Нода"
onChange={(e) => handleInboundChange(inb.id, 'nodeId', e.target.value)}
>
<MenuItem value="">Основная нода</MenuItem>
{nodes.map((node) => (
<MenuItem key={node.id} value={node.id}>
{node.name}{node.isMain ? ' (основная)' : ''}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel>Relay</InputLabel>
<Select
value={inb.relayServerId || ''}
label="Relay"
onChange={(e) => handleInboundChange(inb.id, 'relayServerId', e.target.value)}
>
<MenuItem value="">Без relay</MenuItem>
{getRelayOptions(inb.nodeId).map((tunnel) => (
<MenuItem key={tunnel.id} value={tunnel.id.toString()}>
{tunnel.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<TextField
size="small"
@@ -653,4 +873,4 @@ export default function SubscriptionsPage() {
</Snackbar>
</Box>
);
}
}
+212 -155
View File
@@ -1,21 +1,39 @@
import React, { useEffect, useState, useCallback } from 'react';
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress,
useTheme,
useMediaQuery,
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
RadioGroup,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Paper,
Radio,
RadioGroup,
Select,
Snackbar,
Alert
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import { Add, CheckCircle, Delete, Dns, Error, Terminal } from '@mui/icons-material';
import api from '../api';
import { getApiErrorMessage } from '../utils/errorHandlers';
import { Logger } from '../utils/logger';
import type { NodeRecord } from '../types/node';
interface Tunnel {
id: number;
@@ -24,83 +42,104 @@ interface Tunnel {
sshPort: number;
username: string;
isInstalled: boolean;
nodeId?: string;
node?: NodeRecord;
}
const emptyForm = {
name: '',
nodeId: '',
sshPort: 22,
username: 'root',
password: '',
privateKey: '',
domain: '',
};
export default function TunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [open, setOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
const [form, setForm] = useState(emptyForm);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [snackbar, setSnackbar] = useState({
open: false,
type: 'success' as 'success' | 'error',
message: '',
});
const [confirmDialog, setConfirmDialog] = useState({
open: false,
title: '',
confirmText: 'Подтвердить',
confirmColor: 'primary' as 'primary' | 'error',
onConfirm: () => { },
});
// Snackbar state for notifications
const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const mainNode = useMemo(() => nodes.find((node) => node.isMain), [nodes]);
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
const loadData = useCallback(async () => {
try {
const [tunnelsRes, nodesRes] = await Promise.all([
api.get<Tunnel[]>('/tunnels'),
api.get<NodeRecord[]>('/nodes'),
]);
setTunnels(tunnelsRes.data);
setNodes(nodesRes.data);
} catch (error) {
Logger.error('Failed to load forwarding data', 'Tunnels', error);
}
}, []);
// Form validation errors
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadData();
}, [loadData]);
const getNodeAddress = (node?: NodeRecord) => {
if (!node?.url) return '';
try {
return new URL(node.url).hostname;
} catch {
return node.url;
}
};
const selectedNode = nodes.find((node) => node.id === form.nodeId) || mainNode;
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.name.trim()) errors.name = 'Введите название relay сервера';
if (!selectedNode) errors.nodeId = 'Добавьте или выберите ноду';
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 ключа';
}
if (!form.username.trim()) errors.username = 'Введите SSH пользователя';
if (authMethod === 'password' && !form.password) errors.password = 'Введите SSH пароль';
if (authMethod === 'key' && !form.privateKey.trim()) 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);
}
}, []);
const handleChange =
(prop: keyof typeof emptyForm) => (event: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [prop]: event.target.value }));
};
useEffect(() => { loadTunnels(); }, [loadTunnels]);
const resetForm = () => {
setForm({ ...emptyForm, nodeId: mainNode?.id || '' });
setAuthMethod('password');
setFormErrors({});
};
const openCreate = () => {
resetForm();
setOpen(true);
};
const handleCreate = async () => {
if (!validateForm()) {
@@ -110,67 +149,77 @@ export default function TunnelsPage() {
const payload = {
...form,
password: authMethod === 'password' ? form.password : null,
privateKey: authMethod === 'key' ? form.privateKey : null,
nodeId: form.nodeId || mainNode?.id,
password: authMethod === 'password' ? form.password : undefined,
privateKey: authMethod === 'key' ? form.privateKey : undefined,
};
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: 'Сервер добавлен' });
resetForm();
loadData();
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
};
const handleDelete = async (id: number) => {
const handleInstall = (id: number) => {
setConfirmDialog({
open: true,
title: 'Удалить сервер из списка?',
title: 'Установить перенаправление на выбранный сервер?',
confirmText: 'Установить',
confirmColor: 'primary',
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) => {
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 });
setSnackbar({ open: true, type: 'success', message: 'Перенаправление установлено' });
loadData();
} catch (error) {
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Ошибка установки') });
} finally {
setLoadingId(null);
}
}
},
});
};
const handleChange = useCallback((prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm(prev => ({ ...prev, [prop]: e.target.value }));
}, []);
const handleDelete = (tunnel: Tunnel) => {
const deleteForwarding =
tunnel.isInstalled &&
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh?');
setConfirmDialog({
open: true,
title: deleteForwarding
? 'Удалить relay и выполнить удаление перенаправления на сервере?'
: 'Удалить relay сервер только из списка?',
confirmText: 'Удалить',
confirmColor: 'error',
onConfirm: async () => {
setLoadingId(tunnel.id);
try {
await api.delete(`/tunnels/${tunnel.id}`, {
params: { deleteForwarding },
});
setSnackbar({ open: true, type: 'success', message: 'Relay сервер удалён' });
loadData();
} catch (error) {
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Ошибка удаления') });
} finally {
setLoadingId(null);
}
},
});
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
<Box>
<Button variant="contained" startIcon={<Add />} onClick={openCreate}>
Добавить
</Button>
</Box>
</Box>
<Paper sx={{ overflowX: 'auto' }}>
@@ -178,65 +227,62 @@ export default function TunnelsPage() {
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Нода</TableCell>
<TableCell>Адрес</TableCell>
<TableCell>Статус</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tunnels.map((t) => (
<TableRow key={t.id}>
<TableCell>{t.name}</TableCell>
{tunnels.map((tunnel) => (
<TableRow key={tunnel.id}>
<TableCell>{tunnel.name}</TableCell>
<TableCell>{tunnel.node?.name || '-'}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Dns fontSize="small" color="action" />
{t.ip}
{tunnel.ip}
</Box>
</TableCell>
<TableCell>
{!isMobile && (t.isInstalled ?
<Chip icon={<CheckCircle />} label={"Активен"} color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label={"Не настроен"} color="warning" size="small" variant="outlined" />
)}
{isMobile && (t.isInstalled ?
<CheckCircle color='success' /> :
<Error color='warning' />
{tunnel.isInstalled ? (
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" />
) : (
<Chip icon={<Error />} label="Не установлен" color="warning" size="small" variant="outlined" />
)}
</TableCell>
<TableCell align="right">
{!t.isInstalled && (
<>
{isMobile ? (
<IconButton disabled={loadingId !== null} color="primary" onClick={() => handleInstall(t.id)}>
{loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
</IconButton>
) : (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{isMobile ? '' : (loadingId === t.id ? 'Установка...' : 'Установить')}
</Button>
)}
</>
{!tunnel.isInstalled && (
<Button
startIcon={loadingId === tunnel.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(tunnel.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
Установить
</Button>
)}
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<IconButton color="error" disabled={loadingId !== null} onClick={() => handleDelete(tunnel)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>Нет серверов</TableCell></TableRow>}
{tunnels.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ color: 'text.secondary' }}>
Relay серверы не добавлены
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новый редирект сервер</DialogTitle>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Новый relay сервер</DialogTitle>
<DialogContent>
<TextField
margin="dense"
@@ -247,19 +293,36 @@ export default function TunnelsPage() {
error={!!formErrors.name}
helperText={formErrors.name}
/>
<FormControl fullWidth margin="dense" error={!!formErrors.nodeId}>
<InputLabel>Нода</InputLabel>
<Select
value={form.nodeId || mainNode?.id || ''}
label="Нода"
onChange={(event) => setForm((prev) => ({ ...prev, nodeId: event.target.value }))}
>
{nodes.map((node) => (
<MenuItem key={node.id} value={node.id}>
{node.name}{node.isMain ? ' (основная)' : ''}
</MenuItem>
))}
</Select>
{formErrors.nodeId && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>
{formErrors.nodeId}
</Typography>
)}
</FormControl>
<TextField
margin="dense"
label="IP адрес"
label="IP из URL ноды"
fullWidth
value={form.ip}
onChange={handleChange('ip')}
error={!!formErrors.ip}
helperText={formErrors.ip}
value={getNodeAddress(selectedNode)}
slotProps={{ input: { readOnly: true } }}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
margin="dense"
label="SSH Порт"
label="SSH порт"
type="number"
fullWidth
value={form.sshPort}
@@ -269,7 +332,7 @@ export default function TunnelsPage() {
/>
<TextField
margin="dense"
label="SSH User"
label="SSH пользователь"
fullWidth
value={form.username}
onChange={handleChange('username')}
@@ -287,7 +350,7 @@ export default function TunnelsPage() {
{authMethod === 'password' ? (
<TextField
margin="dense"
label="SSH Пароль"
label="SSH пароль"
type="password"
fullWidth
value={form.password}
@@ -298,13 +361,13 @@ export default function TunnelsPage() {
) : (
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
label="SSH private key"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
error={!!formErrors.privateKey}
helperText={formErrors.privateKey}
@@ -317,7 +380,6 @@ export default function TunnelsPage() {
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
<DialogTitle>Подтверждение</DialogTitle>
<DialogContent>
@@ -327,32 +389,27 @@ export default function TunnelsPage() {
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
<Button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog({ ...confirmDialog, open: false });
confirmDialog.onConfirm();
}}
variant="contained"
color="error"
color={confirmDialog.confirmColor}
>
Подтвердить
{confirmDialog.confirmText}
</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%' }}
>
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
}
+28
View File
@@ -0,0 +1,28 @@
export type NodeAuthType = 'password' | 'token';
export type NodeProtocol = 'http' | 'https';
export interface NodeRecord {
id: string;
name: string;
url: string;
host?: string;
port?: number;
protocol?: NodeProtocol;
authType: NodeAuthType;
login?: string;
isMain: boolean;
version?: string;
createdAt: string;
updatedAt: string;
}
export interface NodePayload {
name: string;
url: string;
authType: NodeAuthType;
login?: string;
password?: string;
token?: string;
isMain?: boolean;
version?: string;
}