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 (
+
+
+
+ Ноды
+
+
+
+ } variant="outlined" onClick={syncNodes}>
+ Синхронизировать
+
+ } variant="contained" onClick={openCreate}>
+ Добавить
+
+
+
+
+
+
+
+
+
+ Название
+ 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 && (
+
+
+ Ноды не добавлены
+
+
+ )}
+
+
+
+
+
+
+ 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
-
-
-
-
-
-
-
-
-
-
-
-
- Генерация инбаундов
-
-
- мин }
- }}
- 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 && (
-
- handleBulkUpdate(true)}
- >
- Включить для всех
-
- handleBulkUpdate(false)}
- >
- Выключить для всех
-
-
- )}
-
-
-
- Доступ к 3DP-MANAGER
-
-
-
-
-
- Обновить профиль
-
-
-
-
-
+
+
- setMsg({ ...msg, open: false })}>
- {msg.text}
+ setMessage({ ...message, open: false })}
+ >
+ {message.text}
-
-
);
-}
\ 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 серверы
- } onClick={() => setOpen(true)}>Добавить
+
+ } onClick={openCreate}>
+ Добавить
+
+
@@ -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 ? : }
-
- ) : (
- : }
- disabled={loadingId !== null}
- onClick={() => handleInstall(t.id)}
- sx={{ mr: 1 }}
- variant="outlined"
- size="small"
- >
- {isMobile ? '' : (loadingId === t.id ? 'Установка...' : 'Установить')}
-
- )}
- >
+ {!tunnel.isInstalled && (
+ : }
+ disabled={loadingId !== null}
+ onClick={() => handleInstall(tunnel.id)}
+ sx={{ mr: 1 }}
+ variant="outlined"
+ size="small"
+ >
+ Установить
+
)}
- handleDelete(t.id)}>
+ handleDelete(tunnel)}>
))}
- {tunnels.length === 0 && Нет серверов}
+ {tunnels.length === 0 && (
+
+
+ Relay серверы не добавлены
+
+
+ )}
-
);
-}
\ 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;
+}