From 962544820fcfefa0127e1389052c3897c92bc05a Mon Sep 17 00:00:00 2001 From: Den Piligrim <89912505+vasiljevdenis@users.noreply.github.com> Date: Mon, 18 May 2026 15:52:15 +0300 Subject: [PATCH] client reafactor --- client/src/App.tsx | 4 +- client/src/components/Layout.tsx | 50 +- client/src/features/nodes/api.ts | 51 ++ client/src/pages/NodesPage.tsx | 300 ++++++++++++ client/src/pages/SettingsPage.tsx | 642 +++---------------------- client/src/pages/SubscriptionsPage.tsx | 238 ++++++++- client/src/pages/TunnelsPage.tsx | 367 ++++++++------ client/src/types/node.ts | 28 ++ 8 files changed, 918 insertions(+), 762 deletions(-) create mode 100644 client/src/features/nodes/api.ts create mode 100644 client/src/pages/NodesPage.tsx create mode 100644 client/src/types/node.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 64e1760..698250b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> @@ -41,4 +43,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 29b7269..5a44342 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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: , path: '/' }, + { text: 'Домены', icon: , path: '/domains' }, + { text: 'Ноды', icon: , path: '/nodes' }, + { text: 'Relay серверы', icon: , path: '/tunnels' }, + { text: 'Настройки', icon: , 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: , path: '/' }, - { text: 'Домены', icon: , path: '/domains' }, - { text: 'Перенаправление', icon: , path: '/tunnels' }, - { text: 'Настройки', icon: , path: '/settings' }, - ]; - const drawerContent = ( {menuItems.map((item) => ( - { navigate(item.path); @@ -56,11 +65,10 @@ export default function Layout() { return ( - {/* Передаем функцию открытия в Header */}
- @@ -92,4 +100,4 @@ export default function Layout() { ); -} \ No newline at end of file +} diff --git a/client/src/features/nodes/api.ts b/client/src/features/nodes/api.ts new file mode 100644 index 0000000..b04c639 --- /dev/null +++ b/client/src/features/nodes/api.ts @@ -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('/nodes'); + return data; + }, + + async create(payload: NodePayload) { + const { data } = await api.post('/nodes', payload); + return data; + }, + + async update(id: string, payload: Partial) { + const { data } = await api.put(`/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(`/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; + }, +}; diff --git a/client/src/pages/NodesPage.tsx b/client/src/pages/NodesPage.tsx new file mode 100644 index 0000000..c9a7954 --- /dev/null +++ b/client/src/pages/NodesPage.tsx @@ -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([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [checkingId, setCheckingId] = useState(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 = (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 ( + + + + Ноды + + + + + + + + + + + + + + Название + URL панели + Авторизация + Статус + Действия + + + + {nodes.map((node) => ( + + + + nodesApi.setMain(node.id).then(loadNodes)} title={node.isMain ? '' : 'Сделать основной'}> + {node.isMain ? : } + + {node.name} + + + + {node.url} + + {node.authType} + + {node.isMain && } label="Основная" color="success" size="small" />} + + + openEdit(node)}> + + + nodesApi.remove(node.id).then(loadNodes)}> + + + + + ))} + {nodes.length === 0 && ( + + + Ноды не добавлены + + + )} + +
+
+ + setOpen(false)} maxWidth="sm" fullWidth> + {editing ? 'Редактировать ноду' : 'Новая нода'} + + + updateField('name', e.target.value)} /> + updateField('url', e.target.value)} + /> + + Тип авторизации + + + {form.authType === 'password' ? ( + + updateField('login', e.target.value)} /> + updateField('password', e.target.value)} + /> + + ) : ( + updateField('token', e.target.value)} + /> + )} + + updateField('isMain', e.target.checked)} /> + Сделать основной нодой + + + + + + + + + + + setMessage({ ...message, open: false })}> + {message.text} + +
+ ); +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 66b6d0e..28cb887 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -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([]); - - const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' }); - const [loadingRotate, setLoadingRotate] = useState(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>('/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) => { + 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) => { - 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) => { - 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 ( - Настройки утилиты + + Настройки + - - - - - - - Статус сервиса - - {isPaused ? - } label="Остановлен" color="warning" size="small" variant="outlined" /> : - } label="Активен" color="success" size="small" variant="outlined" /> - } - - - - {isPaused ? : } - - - - {/* Последняя генерация */} - - - - - Последняя генерация - - - {formatDate(settings.last_rotation_timestamp)} - - - - - - {/* Следующая генерация */} - - - - - Следующая генерация - - - {getNextRotationDate()} - - - - - - - - - - Панель 3x-ui - - - - - - - - {settings.xui_url && settings.xui_login && settings.xui_password && ( - - )} - - - - - - - - Генерация инбаундов - - - мин } - }} - helperText="Как часто менять инбаунды (минимум 10 мин)" - /> - - {ROTATION_PRESETS.map((preset) => ( - handlePresetClick(preset.value)} - color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"} - variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"} - clickable - /> - ))} - - - - - - - - Управление авторотацией подписок - - - Выберите подписки для автоматической ротации: - - - {subs.length === 0 ? ( - - Нет активных подписок - - ) : ( - - {subs.map(sub => ( - - handleToggleAutoRotation(sub.id, e.target.checked)} - color="primary" - /> - } - label={ - - {sub.name} - - {sub.uuid.substring(0, 8)}... - - - } - sx={{ flexGrow: 1 }} - /> - - handleManualRotate(sub)} - color="primary" - > - - - - - ))} - - )} - - {subs.length > 0 && ( - - - - - )} - - - - Доступ к 3DP-MANAGER - - - - - - - - - + + - setMsg({ ...msg, open: false })}> - {msg.text} + setMessage({ ...message, open: false })} + > + {message.text} - - setConfirmDialog({ ...confirmDialog, open: false })}> - Подтверждение действия - - {confirmDialog.title} - - - - - - ); -} \ No newline at end of file +} diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx index c47dc5d..8e62c5a 100644 --- a/client/src/pages/SubscriptionsPage.tsx +++ b/client/src/pages/SubscriptionsPage.tsx @@ -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([]); const [tunnels, setTunnels] = useState([]); + const [nodes, setNodes] = useState([]); const [selectedServer, setSelectedServer] = useState('main'); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [activeSub, setActiveSub] = useState(null); const [domains, setDomains] = useState([]); + 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('/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 ( @@ -388,6 +526,56 @@ export default function SubscriptionsPage() { + + + + Статус ротации + : } + label={rotationSettings.rotation_status === 'stopped' ? 'Остановлена' : 'Активна'} + color={rotationSettings.rotation_status === 'stopped' ? 'warning' : 'success'} + size="small" + variant="outlined" + sx={{ mt: 1 }} + /> + + + + + {rotationSettings.rotation_status === 'stopped' ? : } + + + + + + Последняя генерация + {formatRotationDate(rotationSettings.last_rotation_timestamp)} + + + Следующая генерация + {getNextRotationDate()} + + setRotationSettings((prev) => ({ ...prev, rotation_interval: e.target.value }))} + sx={{ width: { xs: '100%', md: 150 } }} + /> + + + + + + + + @@ -497,7 +685,7 @@ export default function SubscriptionsPage() { setName(e.target.value)} - sx={{ mb: 4 }} + sx={{ mb: 2 }} /> @@ -534,6 +722,38 @@ export default function SubscriptionsPage() { ) : ( <> + + Нода + + + + + Relay + + + ); -} \ No newline at end of file +} diff --git a/client/src/pages/TunnelsPage.tsx b/client/src/pages/TunnelsPage.tsx index d033c3a..99d1011 100644 --- a/client/src/pages/TunnelsPage.tsx +++ b/client/src/pages/TunnelsPage.tsx @@ -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([]); + const [nodes, setNodes] = useState([]); const [open, setOpen] = useState(false); const [loadingId, setLoadingId] = useState(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>({}); + 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('/tunnels'), + api.get('/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>({}); + 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 = {}; - 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) => { + 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) => { - 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 ( Relay серверы - + + + @@ -178,65 +227,62 @@ export default function TunnelsPage() { Название + Нода Адрес Статус Действия - {tunnels.map((t) => ( - - {t.name} + {tunnels.map((tunnel) => ( + + {tunnel.name} + {tunnel.node?.name || '-'} - {t.ip} + {tunnel.ip} - {!isMobile && (t.isInstalled ? - } label={"Активен"} color="success" size="small" variant="outlined" /> : - } label={"Не настроен"} color="warning" size="small" variant="outlined" /> - )} - {isMobile && (t.isInstalled ? - : - + {tunnel.isInstalled ? ( + } label="Активен" color="success" size="small" variant="outlined" /> + ) : ( + } label="Не установлен" color="warning" size="small" variant="outlined" /> )} - {!t.isInstalled && ( - <> - {isMobile ? ( - handleInstall(t.id)}> - {loadingId === t.id ? : } - - ) : ( - - )} - + {!tunnel.isInstalled && ( + )} - handleDelete(t.id)}> + handleDelete(tunnel)}> ))} - {tunnels.length === 0 && Нет серверов} + {tunnels.length === 0 && ( + + + Relay серверы не добавлены + + + )}
- setOpen(false)}> - Новый редирект сервер + setOpen(false)} maxWidth="sm" fullWidth> + Новый relay сервер + + Нода + + {formErrors.nodeId && ( + + {formErrors.nodeId} + + )} + - {/* Confirmation Dialog */} setConfirmDialog({ ...confirmDialog, open: false })}> Подтверждение @@ -327,32 +389,27 @@ export default function TunnelsPage() { - {/* Snackbar notifications */} setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setSnackbar({ ...snackbar, open: false })} - severity={snackbar.type} - sx={{ width: '100%' }} - > + setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}> {snackbar.message} ); -} \ No newline at end of file +} diff --git a/client/src/types/node.ts b/client/src/types/node.ts new file mode 100644 index 0000000..847a3d0 --- /dev/null +++ b/client/src/types/node.ts @@ -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; +}