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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m27s
This commit is contained in:
+10
-1
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user