This commit is contained in:
Den Piligrim
2026-05-21 22:58:29 +03:00
parent 962544820f
commit 26f3bb2934
48 changed files with 2877 additions and 1170 deletions
+1 -1
View File
@@ -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' },
];
+11
View File
@@ -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
View File
@@ -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;
}
}
+1
View File
@@ -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'
+8
View File
@@ -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
View File
@@ -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
+44 -120
View File
@@ -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
View File
@@ -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;
+99
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = '2.1.0';
export const APP_VERSION = '2.2.0';