feat: Introduce server type management in ServerManager, enhancing server configuration with type selection and validation, and update example-servers.json to include server types for improved data consistency.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s

This commit is contained in:
2025-12-06 02:16:37 +07:00
parent 7ccb3118aa
commit f8f8be44ca
3 changed files with 116 additions and 16 deletions
+10 -1
View File
@@ -295,10 +295,19 @@ app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
// === JSON DATA ROUTES (используют фабрики) === // === JSON DATA ROUTES (используют фабрики) ===
// Servers // Servers
const SERVER_TYPES = ['jumphost', 'exit'];
const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => { const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => {
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) { if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
return `Server at index ${i} is missing required fields`; return `Server at index ${i} is missing required fields`;
} }
const normalizedType = String(server.type || '').toLowerCase();
if (!SERVER_TYPES.includes(normalizedType)) {
return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`;
}
// Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения
server.type = normalizedType;
return null; return null;
}); });
+10 -5
View File
@@ -4,34 +4,39 @@
"dns": "srv1.shx.su", "dns": "srv1.shx.su",
"country": "RU", "country": "RU",
"provider": "Yandex", "provider": "Yandex",
"tunnel": "GRE" "tunnel": "GRE",
"type": "jumphost"
}, },
{ {
"ip": "10.0.0.5", "ip": "10.0.0.5",
"dns": "srv2.shx.su", "dns": "srv2.shx.su",
"country": "US", "country": "US",
"provider": "AWS", "provider": "AWS",
"tunnel": "IPSec" "tunnel": "IPSec",
"type": "exit"
}, },
{ {
"ip": "172.16.0.10", "ip": "172.16.0.10",
"dns": "srv3.shx.su", "dns": "srv3.shx.su",
"country": "DE", "country": "DE",
"provider": "Hetzner", "provider": "Hetzner",
"tunnel": "GRE" "tunnel": "GRE",
"type": "jumphost"
}, },
{ {
"ip": "8.8.8.8", "ip": "8.8.8.8",
"dns": "dns.google", "dns": "dns.google",
"country": "US", "country": "US",
"provider": "Google", "provider": "Google",
"tunnel": "IPSec" "tunnel": "IPSec",
"type": "exit"
}, },
{ {
"ip": "1.1.1.1", "ip": "1.1.1.1",
"dns": "cloudflare-dns.com", "dns": "cloudflare-dns.com",
"country": "US", "country": "US",
"provider": "Cloudflare", "provider": "Cloudflare",
"tunnel": "GRE" "tunnel": "GRE",
"type": "jumphost"
} }
] ]
+96 -10
View File
@@ -27,6 +27,10 @@ import {
import GraphView from './GraphView'; import GraphView from './GraphView';
const API_URL = '/api'; const API_URL = '/api';
const SERVER_TYPE_OPTIONS = [
{ value: 'jumphost', label: 'Jumphost' },
{ value: 'exit', label: 'Выходная нода' },
];
function ServerManager() { function ServerManager() {
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
@@ -39,6 +43,7 @@ function ServerManager() {
country: '', country: '',
provider: '', provider: '',
tunnel: 'GRE', tunnel: 'GRE',
type: 'jumphost',
gateway: '' gateway: ''
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -57,6 +62,7 @@ function ServerManager() {
const [filterTunnel, setFilterTunnel] = useState(''); const [filterTunnel, setFilterTunnel] = useState('');
const [filterCountry, setFilterCountry] = useState(''); const [filterCountry, setFilterCountry] = useState('');
const [filterProvider, setFilterProvider] = useState(''); const [filterProvider, setFilterProvider] = useState('');
const [filterType, setFilterType] = useState('');
const editInputRef = useRef(null); const editInputRef = useRef(null);
const pageSize = 10; const pageSize = 10;
@@ -100,6 +106,11 @@ function ServerManager() {
const [graphProviderFilter, setGraphProviderFilter] = useState(''); const [graphProviderFilter, setGraphProviderFilter] = useState('');
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null }); const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
const normalizeServerType = (type) => {
const value = String(type || '').toLowerCase();
return SERVER_TYPE_OPTIONS.some((opt) => opt.value === value) ? value : 'jumphost';
};
useEffect(() => { useEffect(() => {
fetchServers(); fetchServers();
fetchConnections(); fetchConnections();
@@ -122,7 +133,11 @@ function ServerManager() {
setLoading(true); setLoading(true);
try { try {
const response = await api.get(`/servers`); const response = await api.get(`/servers`);
setServers(response.data); const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
...srv,
type: normalizeServerType(srv.type),
}));
setServers(normalized);
setError(''); setError('');
} catch (error) { } catch (error) {
console.error('Error fetching servers:', error); console.error('Error fetching servers:', error);
@@ -147,7 +162,7 @@ function ServerManager() {
}; };
const handleAddServer = () => { const handleAddServer = () => {
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim()) { if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) {
setError('Все обязательные поля должны быть заполнены.'); setError('Все обязательные поля должны быть заполнены.');
return; return;
} }
@@ -156,6 +171,7 @@ function ServerManager() {
const serverToAdd = { const serverToAdd = {
...newServer, ...newServer,
id: newServer.id || nextId, id: newServer.id || nextId,
type: normalizeServerType(newServer.type),
}; };
setServers([...servers, serverToAdd]); setServers([...servers, serverToAdd]);
setNewServer({ setNewServer({
@@ -167,6 +183,7 @@ function ServerManager() {
country: '', country: '',
provider: '', provider: '',
tunnel: 'GRE', tunnel: 'GRE',
type: 'jumphost',
gateway: '' gateway: ''
}); });
}; };
@@ -187,6 +204,7 @@ function ServerManager() {
country: '', country: '',
provider: '', provider: '',
tunnel: 'GRE', tunnel: 'GRE',
type: 'jumphost',
gateway: '' gateway: ''
}); });
setCustomProvider(''); setCustomProvider('');
@@ -194,7 +212,7 @@ function ServerManager() {
}; };
const handleAddServerFromModal = () => { const handleAddServerFromModal = () => {
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.gateway.trim()) { if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.gateway.trim() || !newServer.type) {
setError('Все обязательные поля должны быть заполнены.'); setError('Все обязательные поля должны быть заполнены.');
return; return;
} }
@@ -214,6 +232,7 @@ function ServerManager() {
...newServer, ...newServer,
provider: finalProvider, provider: finalProvider,
id: newServer.id || nextId, id: newServer.id || nextId,
type: normalizeServerType(newServer.type),
}; };
setServers([...servers, serverToAdd]); setServers([...servers, serverToAdd]);
@@ -226,6 +245,7 @@ function ServerManager() {
country: '', country: '',
provider: '', provider: '',
tunnel: 'GRE', tunnel: 'GRE',
type: 'jumphost',
gateway: '' gateway: ''
}); });
setCustomProvider(''); setCustomProvider('');
@@ -244,10 +264,11 @@ function ServerManager() {
}; };
const handleEditModalSave = (updatedServer) => { const handleEditModalSave = (updatedServer) => {
const normalizedServer = { ...updatedServer, type: normalizeServerType(updatedServer.type) };
setServers(servers.map(s => { setServers(servers.map(s => {
const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false; const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false;
const sameIp = s.ip === updatedServer.ip; const sameIp = s.ip === updatedServer.ip;
return (sameId || sameIp) ? { ...s, ...updatedServer } : s; return (sameId || sameIp) ? { ...s, ...normalizedServer } : s;
})); }));
setEditModalOpen(false); setEditModalOpen(false);
setEditModalServer(null); setEditModalServer(null);
@@ -334,9 +355,10 @@ function ServerManager() {
const filtered = servers const filtered = servers
.filter(s => (!filterTunnel || s.tunnel === filterTunnel)) .filter(s => (!filterTunnel || s.tunnel === filterTunnel))
.filter(s => (!filterCountry || s.country === filterCountry)) .filter(s => (!filterCountry || s.country === filterCountry))
.filter(s => (!filterProvider || s.provider === filterProvider)); .filter(s => (!filterProvider || s.provider === filterProvider))
.filter(s => (!filterType || s.type === filterType));
const csvContent = [ const csvContent = [
['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Tunnel', 'Generated URL'], ['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'],
...filtered.map(server => [ ...filtered.map(server => [
server.id || '', server.id || '',
server.ip, server.ip,
@@ -345,6 +367,7 @@ function ServerManager() {
server.dns, server.dns,
server.country, server.country,
server.provider, server.provider,
server.type,
server.tunnel, server.tunnel,
generateServerUrl(server) generateServerUrl(server)
]) ])
@@ -385,7 +408,9 @@ function ServerManager() {
const handleSaveChanges = async () => { const handleSaveChanges = async () => {
setLoading(true); setLoading(true);
try { try {
await api.post(`/servers`, { domains: servers }); const normalizedServers = servers.map((s) => ({ ...s, type: normalizeServerType(s.type) }));
setServers(normalizedServers);
await api.post(`/servers`, { domains: normalizedServers });
await api.post(`/server-connections`, { domains: connections }); await api.post(`/server-connections`, { domains: connections });
setSuccess('Изменения по серверам и связям успешно сохранены!'); setSuccess('Изменения по серверам и связям успешно сохранены!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
@@ -440,12 +465,17 @@ function ServerManager() {
filteredServers = filteredServers.filter(s => s.provider === filterProvider); filteredServers = filteredServers.filter(s => s.provider === filterProvider);
} }
if (filterType) {
filteredServers = filteredServers.filter(s => s.type === filterType);
}
// Поиск // Поиск
filteredServers = filteredServers.filter(s => filteredServers = filteredServers.filter(s =>
s.ip.toLowerCase().includes(searchTerm.toLowerCase()) || s.ip.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.dns.toLowerCase().includes(searchTerm.toLowerCase()) || s.dns.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.country.toLowerCase().includes(searchTerm.toLowerCase()) || s.country.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.provider.toLowerCase().includes(searchTerm.toLowerCase()) s.provider.toLowerCase().includes(searchTerm.toLowerCase()) ||
(s.type || '').toLowerCase().includes(searchTerm.toLowerCase())
); );
// Пагинация // Пагинация
@@ -456,6 +486,7 @@ function ServerManager() {
const allTunnels = Array.from(new Set(servers.map(s => s.tunnel))); const allTunnels = Array.from(new Set(servers.map(s => s.tunnel)));
const allCountries = Array.from(new Set(servers.map(s => s.country))); const allCountries = Array.from(new Set(servers.map(s => s.country)));
const allProviders = Array.from(new Set(servers.map(s => s.provider))); const allProviders = Array.from(new Set(servers.map(s => s.provider)));
const allTypes = Array.from(new Set(servers.map(s => s.type).filter(Boolean)));
// Сортировка по клику // Сортировка по клику
const handleSort = (field) => { const handleSort = (field) => {
@@ -617,6 +648,12 @@ function ServerManager() {
<option key={provider} value={provider}>{provider}</option> <option key={provider} value={provider}>{provider}</option>
))} ))}
</select> </select>
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
<option value="">Все типы</option>
{allTypes.map(type => (
<option key={type} value={type}>{type === 'exit' ? 'Выходная нода' : 'Jumphost'}</option>
))}
</select>
<div className="input-icon flex-grow-1"> <div className="input-icon flex-grow-1">
<span className="input-icon-addon"> <span className="input-icon-addon">
<IconSearch size={18} /> <IconSearch size={18} />
@@ -632,7 +669,7 @@ function ServerManager() {
<button <button
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
type="button" type="button"
onClick={() => { setFilterTunnel(''); setFilterCountry(''); setFilterProvider(''); setSearchTerm(''); setCurrentPage(1); }} onClick={() => { setFilterTunnel(''); setFilterCountry(''); setFilterProvider(''); setFilterType(''); setSearchTerm(''); setCurrentPage(1); }}
title="Сбросить фильтры" title="Сбросить фильтры"
> >
Сбросить Сбросить
@@ -651,6 +688,14 @@ function ServerManager() {
</span> </span>
)} )}
</th> </th>
<th className="cursor-pointer" onClick={() => handleSort('type')}>
Тип
{sortField === 'type' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th> <th>
Внешний IP Внешний IP
</th> </th>
@@ -704,11 +749,16 @@ function ServerManager() {
<tbody> <tbody>
{paginatedServers.length === 0 ? ( {paginatedServers.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или поиск.</td> <td colSpan={11} className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или поиск.</td>
</tr> </tr>
) : paginatedServers.map((server) => ( ) : paginatedServers.map((server) => (
<tr key={server.id || server.ip} className={editingServer === server.ip ? 'table-info' : ''}> <tr key={server.id || server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
<td>{server.ip}</td> <td>{server.ip}</td>
<td>
<span className={`badge ${server.type === 'exit' ? 'bg-red-lt text-danger' : 'bg-blue-lt text-blue'}`}>
{server.type === 'exit' ? 'Выходная нода' : 'Jumphost'}
</span>
</td>
<td>{server.extIp || <span className="text-muted"></span>}</td> <td>{server.extIp || <span className="text-muted"></span>}</td>
<td>{server.internalIp || <span className="text-muted"></span>}</td> <td>{server.internalIp || <span className="text-muted"></span>}</td>
<td>{server.dns}</td> <td>{server.dns}</td>
@@ -934,6 +984,20 @@ function ServerManager() {
/> />
)} )}
<FormField
label="Тип сервера"
name="type"
type="select"
value={newServer.type}
onChange={(val) => setNewServer({ ...newServer, type: val })}
required
options={[
{ value: '', label: 'Выберите тип', disabled: true },
...SERVER_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))
]}
helpText="Выберите роль: jumphost или выходная нода"
/>
<FormField <FormField
label="Тип туннеля" label="Тип туннеля"
name="tunnel" name="tunnel"
@@ -1284,6 +1348,14 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
<label className="form-label">Провайдер</label> <label className="form-label">Провайдер</label>
<input type="text" className="form-control" value={localServer.provider || ''} onChange={e => handleChange('provider', e.target.value)} /> <input type="text" className="form-control" value={localServer.provider || ''} onChange={e => handleChange('provider', e.target.value)} />
</div> </div>
<div className="mb-3">
<label className="form-label">Тип сервера</label>
<select className="form-select" value={localServer.type || 'jumphost'} onChange={e => handleChange('type', e.target.value)}>
{SERVER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="mb-3"> <div className="mb-3">
<label className="form-label">Тип туннеля</label> <label className="form-label">Тип туннеля</label>
<input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleChange('tunnel', e.target.value)} /> <input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleChange('tunnel', e.target.value)} />
@@ -1651,6 +1723,20 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
/> />
)} )}
</div> </div>
<div className="mb-3">
<label className="form-label">Тип сервера *</label>
<select
className="form-select"
value={newServer.type}
onChange={(e) => handleChange('type', e.target.value)}
required
>
<option value="">Выберите тип</option>
{SERVER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="mb-3"> <div className="mb-3">
<label className="form-label">Тип туннеля *</label> <label className="form-label">Тип туннеля *</label>
<select <select