v2.2.0
This commit is contained in:
@@ -23,9 +23,9 @@ 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: <Dns />, path: '/domains' },
|
||||
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ export const nodesApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async detectLocation(url: string) {
|
||||
const { data } = await api.post<{
|
||||
ip?: string;
|
||||
host?: string;
|
||||
flag?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}>('/nodes/detect-location', { url });
|
||||
return data;
|
||||
},
|
||||
|
||||
async syncFromMain() {
|
||||
const { data } = await api.post<{ success: boolean; count: number }>(
|
||||
'/nodes/sync/main',
|
||||
|
||||
+14
-1
@@ -1,3 +1,16 @@
|
||||
html {
|
||||
font-family: "Inter", "Roboto", "Helvetica", "Arial", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.animated-container {
|
||||
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
|
||||
background-size: 400% 400%;
|
||||
@@ -32,4 +45,4 @@
|
||||
|
||||
input:autofill {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import './index.css'
|
||||
import '@fontsource/inter/300.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import App from './App.tsx'
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const emptyDomainsNotified = useRef(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -203,6 +204,13 @@ export default function DomainsPage() {
|
||||
|
||||
setDomains(data.data);
|
||||
setTotalCount(data.total);
|
||||
if (data.total === 0 && !emptyDomainsNotified.current) {
|
||||
emptyDomainsNotified.current = true;
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы один домен!' });
|
||||
}
|
||||
if (data.total > 0) {
|
||||
emptyDomainsNotified.current = false;
|
||||
}
|
||||
Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains');
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load', 'Domains', error);
|
||||
|
||||
+202
-31
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
@@ -30,49 +32,69 @@ import {
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Edit,
|
||||
Refresh,
|
||||
Star,
|
||||
StarBorder,
|
||||
Sync,
|
||||
} from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
import { nodesApi } from '../features/nodes/api';
|
||||
import type { NodeAuthType, NodePayload, NodeRecord } from '../types/node';
|
||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||
import { FlagIcon, FlagOptionLabel } from '../utils/flags';
|
||||
|
||||
const emptyForm: NodePayload = {
|
||||
name: '',
|
||||
url: '',
|
||||
authType: 'password',
|
||||
ip: '',
|
||||
flag: '',
|
||||
authType: 'token',
|
||||
login: '',
|
||||
password: '',
|
||||
token: '',
|
||||
isMain: false,
|
||||
};
|
||||
|
||||
interface CountryOption {
|
||||
name: string;
|
||||
code: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const isValidIp = (value?: string) =>
|
||||
!!value &&
|
||||
(/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim()));
|
||||
|
||||
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 [countries, setCountries] = useState<CountryOption[]>([]);
|
||||
const [detectingLocation, setDetectingLocation] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<NodeRecord | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<Partial<Record<keyof NodePayload, string>>>({});
|
||||
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());
|
||||
const data = await nodesApi.list();
|
||||
setNodes(Array.isArray(data) ? data : []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
api.get<CountryOption[]>('/settings/countries').then((res) => setCountries(Array.isArray(res.data) ? res.data : []));
|
||||
}, [loadNodes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setFormErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
@@ -81,6 +103,8 @@ export default function NodesPage() {
|
||||
setForm({
|
||||
name: node.name,
|
||||
url: node.url || '',
|
||||
ip: node.ip || '',
|
||||
flag: node.flag || '',
|
||||
authType: node.authType,
|
||||
login: node.login || '',
|
||||
password: '',
|
||||
@@ -88,45 +112,103 @@ export default function NodesPage() {
|
||||
isMain: node.isMain,
|
||||
version: node.version || '',
|
||||
});
|
||||
setFormErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof NodePayload>(key: K, value: NodePayload[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setFormErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const validateForm = (requireSecrets = !editing) => {
|
||||
const errors: Partial<Record<keyof NodePayload, string>> = {};
|
||||
|
||||
if (!form.name.trim()) errors.name = 'Введите название ноды';
|
||||
if (!form.url.trim()) errors.url = 'Введите URL панели 3x-ui';
|
||||
if (!isValidIp(form.ip)) errors.ip = 'Введите корректный IP ноды';
|
||||
if (!form.flag) errors.flag = 'Выберите флаг ноды';
|
||||
|
||||
if (form.authType === 'password') {
|
||||
if (!form.login?.trim()) errors.login = 'Введите логин';
|
||||
if (requireSecrets && !form.password?.trim()) errors.password = 'Введите пароль';
|
||||
}
|
||||
|
||||
if (form.authType === 'token' && requireSecrets && !form.token?.trim()) {
|
||||
errors.token = 'Введите токен';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const detectNodeLocation = async () => {
|
||||
const url = form.url.trim();
|
||||
if (!url) return;
|
||||
|
||||
setDetectingLocation(true);
|
||||
try {
|
||||
const result = await nodesApi.detectLocation(url.replace(/\/+$/, ''));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ip: result.ip || prev.ip,
|
||||
flag: result.flag || prev.flag,
|
||||
}));
|
||||
if (result.country || result.ip) {
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: `Определено: ${result.country || 'страна неизвестна'}${result.ip ? `, IP ${result.ip}` : ''}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ open: true, type: 'error', text: 'Не удалось определить страну ноды' });
|
||||
} finally {
|
||||
setDetectingLocation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
|
||||
if (!validateForm(!editing)) {
|
||||
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: NodePayload = {
|
||||
...form,
|
||||
url: form.url.replace(/\/+$/, ''),
|
||||
ip: form.ip || undefined,
|
||||
flag: form.flag || undefined,
|
||||
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);
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
await nodesApi.update(editing.id, payload);
|
||||
} else {
|
||||
await nodesApi.create(payload);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: editing ? 'Нода обновлена' : 'Нода добавлена',
|
||||
});
|
||||
loadNodes();
|
||||
setOpen(false);
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: editing ? 'Нода обновлена' : 'Нода добавлена',
|
||||
});
|
||||
loadNodes();
|
||||
} catch (error: unknown) {
|
||||
const text =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'Не удалось сохранить ноду';
|
||||
setMessage({ open: true, type: 'error', text });
|
||||
}
|
||||
};
|
||||
|
||||
const checkFormConnection = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
|
||||
if (!validateForm(true)) {
|
||||
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля для проверки подключения' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,6 +252,22 @@ export default function NodesPage() {
|
||||
loadNodes();
|
||||
};
|
||||
|
||||
const removeNode = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await nodesApi.remove(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
setMessage({ open: true, type: 'success', text: 'Нода удалена' });
|
||||
loadNodes();
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'error',
|
||||
text: getApiErrorMessage(error, 'Ошибка удаления ноды'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, gap: 2 }}>
|
||||
@@ -178,9 +276,9 @@ export default function NodesPage() {
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
|
||||
{/* <Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
|
||||
Синхронизировать
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button startIcon={<Add />} variant="contained" onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
@@ -193,6 +291,8 @@ export default function NodesPage() {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Название</TableCell>
|
||||
<TableCell>Флаг</TableCell>
|
||||
<TableCell>IP</TableCell>
|
||||
<TableCell>URL панели</TableCell>
|
||||
<TableCell>Авторизация</TableCell>
|
||||
<TableCell>Статус</TableCell>
|
||||
@@ -211,17 +311,22 @@ export default function NodesPage() {
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{node.url}
|
||||
<FlagIcon flag={node.flag} />
|
||||
</TableCell>
|
||||
<TableCell>{node.ip || '-'}</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 disabled={checkingId === node.id} onClick={() => checkNode(node)}>
|
||||
{checkingId === node.id ? <CircularProgress size={20} /> : <CheckCircle />}
|
||||
</IconButton> */}
|
||||
<IconButton onClick={() => openEdit(node)}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton color="error" onClick={() => nodesApi.remove(node.id).then(loadNodes)}>
|
||||
<IconButton color="error" onClick={() => setDeleteTarget(node)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -229,7 +334,7 @@ export default function NodesPage() {
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary' }}>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
|
||||
Ноды не добавлены
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -242,14 +347,55 @@ export default function NodesPage() {
|
||||
<DialogTitle>{editing ? 'Редактировать ноду' : 'Новая нода'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Название" value={form.name} onChange={(e) => updateField('name', e.target.value)} />
|
||||
<TextField
|
||||
label="Название"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
error={!!formErrors.name}
|
||||
helperText={formErrors.name}
|
||||
/>
|
||||
<TextField
|
||||
label="URL панели 3x-ui"
|
||||
helperText="Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH"
|
||||
required
|
||||
helperText={formErrors.url || 'Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH'}
|
||||
value={form.url}
|
||||
onChange={(e) => updateField('url', e.target.value)}
|
||||
onBlur={detectNodeLocation}
|
||||
error={!!formErrors.url}
|
||||
InputProps={{ endAdornment: detectingLocation ? <CircularProgress size={18} /> : undefined }}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label="IP ноды"
|
||||
required
|
||||
fullWidth
|
||||
value={form.ip || ''}
|
||||
onChange={(e) => updateField('ip', e.target.value)}
|
||||
error={!!formErrors.ip}
|
||||
helperText={formErrors.ip}
|
||||
/>
|
||||
<FormControl fullWidth required error={!!formErrors.flag}>
|
||||
<InputLabel>Флаг</InputLabel>
|
||||
<Select
|
||||
value={form.flag || ''}
|
||||
label="Флаг"
|
||||
onChange={(e) => updateField('flag', e.target.value)}
|
||||
renderValue={(value) => (
|
||||
<FlagOptionLabel flag={value} label={countries.find((country) => country.emoji === value)?.name || 'Флаг'} />
|
||||
)}
|
||||
>
|
||||
<MenuItem value="">Без флага</MenuItem>
|
||||
{countries.map((country) => (
|
||||
<MenuItem key={country.code} value={country.emoji}>
|
||||
<FlagOptionLabel flag={country.emoji} code={country.code} label={country.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{formErrors.flag && <FormHelperText>{formErrors.flag}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Тип авторизации</InputLabel>
|
||||
<Select
|
||||
value={form.authType}
|
||||
@@ -262,21 +408,35 @@ export default function NodesPage() {
|
||||
</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="Логин"
|
||||
required
|
||||
fullWidth
|
||||
value={form.login || ''}
|
||||
onChange={(e) => updateField('login', e.target.value)}
|
||||
error={!!formErrors.login}
|
||||
helperText={formErrors.login}
|
||||
/>
|
||||
<TextField
|
||||
label={editing ? 'Новый пароль' : 'Пароль'}
|
||||
type="password"
|
||||
required={!editing}
|
||||
fullWidth
|
||||
value={form.password || ''}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
error={!!formErrors.password}
|
||||
helperText={formErrors.password || (editing ? 'Оставьте пустым, чтобы не менять пароль' : undefined)}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<TextField
|
||||
label={editing ? 'Новый токен' : 'Токен'}
|
||||
type="password"
|
||||
required={!editing}
|
||||
value={form.token || ''}
|
||||
onChange={(e) => updateField('token', e.target.value)}
|
||||
error={!!formErrors.token}
|
||||
helperText={formErrors.token || (editing ? 'Оставьте пустым, чтобы не менять токен' : undefined)}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
@@ -292,6 +452,17 @@ export default function NodesPage() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Удалить ноду?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Вы уверены, что хотите удалить ноду {deleteTarget?.name}?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||
<Button color="error" variant="contained" onClick={removeNode}>Удалить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={message.open} autoHideDuration={5000} onClose={() => setMessage({ ...message, open: false })}>
|
||||
<Alert severity={message.type}>{message.text}</Alert>
|
||||
</Snackbar>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ interface Tunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
domain?: string;
|
||||
sshPort: number;
|
||||
username: string;
|
||||
isInstalled: boolean;
|
||||
@@ -49,6 +50,7 @@ interface Tunnel {
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
nodeId: '',
|
||||
ip: '',
|
||||
sshPort: 22,
|
||||
username: 'root',
|
||||
password: '',
|
||||
@@ -74,7 +76,7 @@ export default function TunnelsPage() {
|
||||
title: '',
|
||||
confirmText: 'Подтвердить',
|
||||
confirmColor: 'primary' as 'primary' | 'error',
|
||||
onConfirm: () => { },
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const theme = useTheme();
|
||||
@@ -87,8 +89,8 @@ export default function TunnelsPage() {
|
||||
api.get<Tunnel[]>('/tunnels'),
|
||||
api.get<NodeRecord[]>('/nodes'),
|
||||
]);
|
||||
setTunnels(tunnelsRes.data);
|
||||
setNodes(nodesRes.data);
|
||||
setTunnels(Array.isArray(tunnelsRes.data) ? tunnelsRes.data : []);
|
||||
setNodes(Array.isArray(nodesRes.data) ? nodesRes.data : []);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load forwarding data', 'Tunnels', error);
|
||||
}
|
||||
@@ -98,14 +100,12 @@ export default function TunnelsPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const getNodeAddress = (node?: NodeRecord) => {
|
||||
if (!node?.url) return '';
|
||||
try {
|
||||
return new URL(node.url).hostname;
|
||||
} catch {
|
||||
return node.url;
|
||||
}
|
||||
};
|
||||
const isValidIp = (value: string) =>
|
||||
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim());
|
||||
const isValidDomain = (value: string) =>
|
||||
/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(value.trim());
|
||||
const isValidAddress = (value: string) => isValidIp(value) || isValidDomain(value);
|
||||
|
||||
const selectedNode = nodes.find((node) => node.id === form.nodeId) || mainNode;
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function TunnelsPage() {
|
||||
|
||||
if (!form.name.trim()) errors.name = 'Введите название relay сервера';
|
||||
if (!selectedNode) errors.nodeId = 'Добавьте или выберите ноду';
|
||||
if (!isValidAddress(form.ip)) errors.ip = 'Введите корректный IP или домен relay сервера';
|
||||
if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) {
|
||||
errors.sshPort = 'Порт должен быть от 1 до 65535';
|
||||
}
|
||||
@@ -137,6 +138,10 @@ export default function TunnelsPage() {
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (nodes.length === 0) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы одну ноду!' });
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
};
|
||||
@@ -154,11 +159,15 @@ export default function TunnelsPage() {
|
||||
privateKey: authMethod === 'key' ? form.privateKey : undefined,
|
||||
};
|
||||
|
||||
await api.post('/tunnels', payload);
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
loadData();
|
||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
|
||||
try {
|
||||
await api.post('/tunnels', payload);
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
loadData();
|
||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Не удалось добавить relay сервер') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = (id: number) => {
|
||||
@@ -185,7 +194,7 @@ export default function TunnelsPage() {
|
||||
const handleDelete = (tunnel: Tunnel) => {
|
||||
const deleteForwarding =
|
||||
tunnel.isInstalled &&
|
||||
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh?');
|
||||
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh (приведет сервер к первоначальному состоянию)? Нажмите Ок для подтверждения.');
|
||||
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
@@ -197,9 +206,7 @@ export default function TunnelsPage() {
|
||||
onConfirm: async () => {
|
||||
setLoadingId(tunnel.id);
|
||||
try {
|
||||
await api.delete(`/tunnels/${tunnel.id}`, {
|
||||
params: { deleteForwarding },
|
||||
});
|
||||
await api.delete(`/tunnels/${tunnel.id}`, { params: { deleteForwarding } });
|
||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер удалён' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
@@ -215,11 +222,8 @@ export default function TunnelsPage() {
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
|
||||
<Box>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
</Box>
|
||||
<Box><Button variant="contained" startIcon={<Add />} onClick={openCreate}>Добавить</Button></Box>
|
||||
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
@@ -241,7 +245,7 @@ export default function TunnelsPage() {
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Dns fontSize="small" color="action" />
|
||||
{tunnel.ip}
|
||||
{tunnel.domain || tunnel.ip}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -284,61 +288,20 @@ export default function TunnelsPage() {
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Новый relay сервер</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Название"
|
||||
fullWidth
|
||||
value={form.name}
|
||||
onChange={handleChange('name')}
|
||||
error={!!formErrors.name}
|
||||
helperText={formErrors.name}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense" error={!!formErrors.nodeId}>
|
||||
<TextField margin="dense" label="Название" required fullWidth value={form.name} onChange={handleChange('name')} error={!!formErrors.name} helperText={formErrors.name} />
|
||||
<FormControl fullWidth required margin="dense" error={!!formErrors.nodeId}>
|
||||
<InputLabel>Нода</InputLabel>
|
||||
<Select
|
||||
value={form.nodeId || mainNode?.id || ''}
|
||||
label="Нода"
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, nodeId: event.target.value }))}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{formErrors.nodeId && <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>{formErrors.nodeId}</Typography>}
|
||||
</FormControl>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="IP из URL ноды"
|
||||
fullWidth
|
||||
value={getNodeAddress(selectedNode)}
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
/>
|
||||
<TextField margin="dense" label="IP или домен relay сервера" required fullWidth value={form.ip} onChange={handleChange('ip')} error={!!formErrors.ip} helperText={formErrors.ip} />
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="SSH порт"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.sshPort}
|
||||
onChange={handleChange('sshPort')}
|
||||
error={!!formErrors.sshPort}
|
||||
helperText={formErrors.sshPort}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="SSH пользователь"
|
||||
fullWidth
|
||||
value={form.username}
|
||||
onChange={handleChange('username')}
|
||||
error={!!formErrors.username}
|
||||
helperText={formErrors.username}
|
||||
/>
|
||||
<TextField margin="dense" label="SSH порт" required type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} error={!!formErrors.sshPort} helperText={formErrors.sshPort} />
|
||||
<TextField margin="dense" label="SSH пользователь" required fullWidth value={form.username} onChange={handleChange('username')} error={!!formErrors.username} helperText={formErrors.username} />
|
||||
</Box>
|
||||
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
|
||||
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
|
||||
@@ -348,30 +311,9 @@ export default function TunnelsPage() {
|
||||
</FormControl>
|
||||
|
||||
{authMethod === 'password' ? (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="SSH пароль"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={form.password}
|
||||
onChange={handleChange('password')}
|
||||
error={!!formErrors.password}
|
||||
helperText={formErrors.password}
|
||||
/>
|
||||
<TextField margin="dense" label="SSH пароль" required type="password" fullWidth value={form.password} onChange={handleChange('password')} error={!!formErrors.password} helperText={formErrors.password} />
|
||||
) : (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="SSH private key"
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={form.privateKey}
|
||||
onChange={handleChange('privateKey')}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
|
||||
error={!!formErrors.privateKey}
|
||||
helperText={formErrors.privateKey}
|
||||
/>
|
||||
<TextField margin="dense" label="SSH private key" required multiline rows={4} fullWidth value={form.privateKey} onChange={handleChange('privateKey')} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }} error={!!formErrors.privateKey} helperText={formErrors.privateKey} />
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -382,33 +324,15 @@ export default function TunnelsPage() {
|
||||
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
<DialogTitle>Подтверждение</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{confirmDialog.title}</Typography>
|
||||
</DialogContent>
|
||||
<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>
|
||||
<Button onClick={() => { setConfirmDialog({ ...confirmDialog, open: false }); confirmDialog.onConfirm(); }} variant="contained" color={confirmDialog.confirmColor}>{confirmDialog.confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
<Snackbar open={snackbar.open} autoHideDuration={6000} onClose={() => setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>{snackbar.message}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface NodeRecord {
|
||||
name: string;
|
||||
url: string;
|
||||
host?: string;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
flag?: string;
|
||||
port?: number;
|
||||
protocol?: NodeProtocol;
|
||||
authType: NodeAuthType;
|
||||
@@ -19,6 +22,9 @@ export interface NodeRecord {
|
||||
export interface NodePayload {
|
||||
name: string;
|
||||
url: string;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
flag?: string;
|
||||
authType: NodeAuthType;
|
||||
login?: string;
|
||||
password?: string;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
|
||||
const SPECIAL_CODES: Record<string, string> = {
|
||||
ENGLAND: 'gb-eng',
|
||||
SCOTLAND: 'gb-sct',
|
||||
WALES: 'gb-wls',
|
||||
};
|
||||
|
||||
export const decodeFlag = (flag?: string) => {
|
||||
if (!flag) return '';
|
||||
try {
|
||||
return decodeURIComponent(flag);
|
||||
} catch {
|
||||
return flag;
|
||||
}
|
||||
};
|
||||
|
||||
export const countryCodeFromFlag = (flag?: string) => {
|
||||
const decoded = decodeFlag(flag);
|
||||
const points = Array.from(decoded).map((char) => char.codePointAt(0) || 0);
|
||||
|
||||
if (points.length < 2) return undefined;
|
||||
if (points[0] < 0x1f1e6 || points[0] > 0x1f1ff) return undefined;
|
||||
if (points[1] < 0x1f1e6 || points[1] > 0x1f1ff) return undefined;
|
||||
|
||||
return String.fromCharCode(points[0] - 0x1f1e6 + 65, points[1] - 0x1f1e6 + 65);
|
||||
};
|
||||
|
||||
const normalizeCode = (code?: string) => {
|
||||
if (!code) return undefined;
|
||||
return SPECIAL_CODES[code.toUpperCase()] || code.toLowerCase();
|
||||
};
|
||||
|
||||
export function FlagIcon({
|
||||
flag,
|
||||
code,
|
||||
size = 22,
|
||||
}: {
|
||||
flag?: string;
|
||||
code?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const normalizedCode = normalizeCode(code || countryCodeFromFlag(flag));
|
||||
|
||||
if (!normalizedCode) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: size,
|
||||
height: Math.round(size * 0.72),
|
||||
borderRadius: 0.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'inline-block',
|
||||
bgcolor: 'action.hover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://flagcdn.com/w40/${normalizedCode}.png`}
|
||||
srcSet={`https://flagcdn.com/w80/${normalizedCode}.png 2x`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: size,
|
||||
height: Math.round(size * 0.72),
|
||||
borderRadius: 0.5,
|
||||
objectFit: 'cover',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlagOptionLabel({
|
||||
flag,
|
||||
code,
|
||||
label,
|
||||
}: {
|
||||
flag?: string;
|
||||
code?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack component="span" direction="row" spacing={1} alignItems="center">
|
||||
<FlagIcon flag={flag} code={code} size={22} />
|
||||
<Typography component="span" variant="body2">
|
||||
{label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '2.1.0';
|
||||
export const APP_VERSION = '2.2.0';
|
||||
|
||||
Reference in New Issue
Block a user