diff --git a/backend/server.js b/backend/server.js index bba58a6..3113e99 100644 --- a/backend/server.js +++ b/backend/server.js @@ -306,6 +306,22 @@ const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => { return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`; } + if (normalizedType === 'jumphost' || normalizedType === 'exit') { + if (!Array.isArray(server.gateways) || server.gateways.length === 0) { + return `Server at index ${i} must have gateways for type ${normalizedType}`; + } + const primaries = server.gateways.filter(g => g && g.primary); + if (primaries.length !== 1) { + return `Server at index ${i} must have exactly one primary gateway`; + } + for (let j = 0; j < server.gateways.length; j++) { + const gw = server.gateways[j] || {}; + if (!gw.name || String(gw.name).trim().length === 0) { + return `Server at index ${i} gateway at index ${j} is missing name`; + } + } + } + // Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения server.type = normalizedType; return null; diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx index c0a7058..c9e736a 100644 --- a/frontend/src/FilterManager.jsx +++ b/frontend/src/FilterManager.jsx @@ -43,39 +43,6 @@ const countryToFlag = (code) => { return String.fromCodePoint(...codePoints); }; -const normalizeGateways = (gateways = [], fallbackName = '') => { - if (!Array.isArray(gateways)) return []; - const cleaned = gateways - .map((g, idx) => { - const name = String(g?.name || '').trim(); - const ip = String(g?.ip || '').trim(); - const comment = String(g?.comment || '').trim(); - const primary = Boolean(g?.primary); - const id = g?.id || `${Date.now()}-${idx}-${Math.random().toString(16).slice(2, 6)}`; - return { id, name, ip, comment, primary }; - }) - .filter(g => g.name || g.ip || g.comment); - if (cleaned.length === 0) { - return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }]; - } - if (!cleaned.some(g => g.primary)) { - cleaned[0].primary = true; - } else { - // если несколько primary, оставляем только первый - let found = false; - cleaned.forEach(g => { - if (g.primary) { - if (!found) { - found = true; - } else { - g.primary = false; - } - } - }); - } - return cleaned; -}; - function FilterManager() { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -90,15 +57,7 @@ function FilterManager() { const [servers, setServers] = useState([]); const [selectedServer, setSelectedServer] = useState(null); const [serverConfigModalOpen, setServerConfigModalOpen] = useState(false); - const makeGateway = (over = {}) => ({ - id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, - name: '', - ip: '', - comment: '', - primary: false, - ...over, - }); - const [newServer, setNewServer] = useState({ name: '', description: '', enabled: true, gateways: [makeGateway({ primary: true })] }); + const [newServer, setNewServer] = useState({ name: '', description: '', enabled: true }); // Состояния для управления фильтрами сервера const [serverFilters, setServerFilters] = useState([]); @@ -112,63 +71,6 @@ function FilterManager() { // Новое состояние для редактирования сервера const [editServerModalOpen, setEditServerModalOpen] = useState(false); const [editingServer, setEditingServer] = useState(null); - - const updateNewGateway = (idx, field, value) => { - setNewServer((prev) => { - const list = prev.gateways.map((g, i) => { - if (i !== idx) return g; - const next = { ...g, [field]: value }; - return next; - }); - if (field === 'primary' && value) { - list.forEach((g, i) => { g.primary = i === idx; }); - } - return { ...prev, gateways: list }; - }); - }; - const addNewGateway = () => { - setNewServer((prev) => ({ ...prev, gateways: [...prev.gateways, makeGateway({ primary: prev.gateways.length === 0 })] })); - }; - const removeNewGateway = (idx) => { - setNewServer((prev) => { - if (prev.gateways.length === 1) return prev; - const list = prev.gateways.filter((_, i) => i !== idx); - if (!list.some(g => g.primary) && list.length > 0) list[0].primary = true; - return { ...prev, gateways: list }; - }); - }; - - const updateEditGateway = (idx, field, value) => { - setEditingServer((prev) => { - if (!prev) return prev; - const list = (prev.gateways || []).map((g, i) => { - if (i !== idx) return g; - const next = { ...g, [field]: value }; - return next; - }); - if (field === 'primary' && value) { - list.forEach((g, i) => { g.primary = i === idx; }); - } - return { ...prev, gateways: list }; - }); - }; - const addEditGateway = () => { - setEditingServer((prev) => { - if (!prev) return prev; - const list = [...(prev.gateways || []), makeGateway({ primary: !(prev.gateways || []).length })]; - return { ...prev, gateways: list }; - }); - }; - const removeEditGateway = (idx) => { - setEditingServer((prev) => { - if (!prev) return prev; - const list = (prev.gateways || []); - if (list.length === 1) return prev; - const next = list.filter((_, i) => i !== idx); - if (!next.some(g => g.primary) && next.length > 0) next[0].primary = true; - return { ...prev, gateways: next }; - }); - }; const [editServerError, setEditServerError] = useState(''); // Состояние поиска/сортировки @@ -176,19 +78,6 @@ function FilterManager() { const [sortBy, setSortBy] = useState('community'); // community | gateway | description const [sortDir, setSortDir] = useState('asc'); // asc | desc - // Инвентарный список servers (dns/ip) для сопоставления по имени - const [inventoryServers, setInventoryServers] = useState([]); - useEffect(() => { - (async () => { - try { - const res = await api.get('/servers'); - setInventoryServers(Array.isArray(res.data) ? res.data : []); - } catch (e) { - // не блокируем UI, если нет инвентаря - } - })(); - }, []); - // Префилл из query (?community=..., ?serverId=..., ?server=...) без автоселекта по умолчанию const queryParamsRef = useRef({ community: null, serverId: null, serverName: null }); const serverPrefillDoneRef = useRef(false); @@ -299,7 +188,6 @@ function FilterManager() { const serverToAdd = { id: Date.now().toString(), ...newServer, - gateways: normalizeGateways(newServer.gateways, newServer.name), createdAt: new Date().toISOString() }; @@ -307,7 +195,7 @@ function FilterManager() { await api.post(`/server-configs`, { servers: updatedServers }); setServers(updatedServers); - setNewServer({ name: '', description: '', enabled: true, gateways: [makeGateway({ primary: true })] }); + setNewServer({ name: '', description: '', enabled: true }); setServerConfigModalOpen(false); setSuccess('Сервер успешно добавлен!'); setTimeout(() => setSuccess(''), 3000); @@ -622,7 +510,7 @@ function FilterManager() { // Открыть модалку редактирования const handleEditServer = (server) => { - setEditingServer({ ...server, gateways: normalizeGateways(server.gateways, server.name) }); + setEditingServer({ ...server }); setEditServerError(''); setEditServerModalOpen(true); }; @@ -634,7 +522,7 @@ function FilterManager() { return; } try { - const edited = { ...editingServer, gateways: normalizeGateways(editingServer.gateways, editingServer.name) }; + const edited = { ...editingServer }; const updatedServers = servers.map(s => s.id === editingServer.id ? { ...s, name: edited.name, description: edited.description, gateways: edited.gateways } : s ); @@ -1631,7 +1519,7 @@ function FilterManager() { show={serverConfigModalOpen} onClose={() => { setServerConfigModalOpen(false); - setNewServer({ name: '', description: '', enabled: true, gateways: [makeGateway({ primary: true })] }); + setNewServer({ name: '', description: '', enabled: true }); }} onSubmit={handleAddServer} title="Добавить новый сервер" @@ -1665,80 +1553,6 @@ function FilterManager() { icon={IconFileText} helpText="Краткое описание сервера (необязательно)" /> - -
- -
- {newServer.gateways.map((gw, idx) => ( -
-
-
- - updateNewGateway(idx, 'name', e.target.value)} - placeholder="GW-MSK" - required - /> -
-
- - updateNewGateway(idx, 'ip', e.target.value)} - placeholder="10.0.0.1" - /> -
-
- - updateNewGateway(idx, 'comment', e.target.value)} - placeholder="Основной шлюз" - /> -
-
-
- updateNewGateway(idx, 'primary', e.target.checked)} - title="Основной шлюз" - /> -
-
-
-
-
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
- {newServer.gateways.length > 1 && ( - - )} -
-
- ))} -
-
- -
Один шлюз должен быть помечен как основной.
-
-
-
-
- -
- {(editingServer.gateways || []).map((gw, idx) => ( -
-
-
- - updateEditGateway(idx, 'name', e.target.value)} - placeholder="GW-MSK" - required - /> -
-
- - updateEditGateway(idx, 'ip', e.target.value)} - placeholder="10.0.0.1" - /> -
-
- - updateEditGateway(idx, 'comment', e.target.value)} - placeholder="Основной шлюз" - /> -
-
-
- updateEditGateway(idx, 'primary', e.target.checked)} - title="Основной шлюз" - /> -
-
-
-
-
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
- {(editingServer.gateways || []).length > 1 && ( - - )} -
-
- ))} -
-
- -
Один шлюз должен быть помечен как основной.
-
-
)} diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index 2743569..ec8e44b 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -31,6 +31,43 @@ const SERVER_TYPE_OPTIONS = [ { value: 'jumphost', label: 'Jumphost' }, { value: 'exit', label: 'Выходная нода' }, ]; +const NEEDS_GATEWAYS = (type) => ['jumphost', 'exit'].includes(String(type || '').toLowerCase()); + +const makeGateway = (over = {}) => ({ + id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + name: '', + ip: '', + comment: '', + primary: false, + ...over, +}); + +const normalizeGateways = (gateways = [], fallbackName = '') => { + if (!Array.isArray(gateways)) return []; + const cleaned = gateways + .map((g, idx) => ({ + id: g?.id || `${Date.now()}-${idx}`, + name: String(g?.name || '').trim(), + ip: String(g?.ip || '').trim(), + comment: String(g?.comment || '').trim(), + primary: Boolean(g?.primary), + })) + .filter(g => g.name || g.ip || g.comment); + if (cleaned.length === 0) { + return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }]; + } + // ensure single primary + let hasPrimary = false; + cleaned.forEach((g, i) => { + if (g.primary && !hasPrimary) { + hasPrimary = true; + } else { + g.primary = false; + } + }); + if (!hasPrimary) cleaned[0].primary = true; + return cleaned; +}; function ServerManager() { const [servers, setServers] = useState([]); @@ -44,7 +81,8 @@ function ServerManager() { provider: '', tunnel: 'GRE', type: 'jumphost', - gateway: '' + gateway: '', + gateways: [makeGateway({ primary: true })], }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -136,6 +174,7 @@ function ServerManager() { const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({ ...srv, type: normalizeServerType(srv.type), + gateways: NEEDS_GATEWAYS(srv.type) ? normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip) : (Array.isArray(srv.gateways) ? srv.gateways : []), })); setServers(normalized); setError(''); @@ -212,10 +251,20 @@ function ServerManager() { }; const handleAddServerFromModal = () => { - if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.gateway.trim() || !newServer.type) { + if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) { setError('Все обязательные поля должны быть заполнены.'); return; } + if (NEEDS_GATEWAYS(newServer.type)) { + const normalizedGws = normalizeGateways(newServer.gateways, newServer.gateway || newServer.dns || newServer.ip); + if (!normalizedGws.length) { + setError('Добавьте хотя бы один gateway и отметьте основной.'); + return; + } + newServer.gateways = normalizedGws; + } else { + newServer.gateways = []; + } // Проверяем, если выбран "Другой" провайдер, то customProvider должен быть заполнен if (newServer.provider === 'Другой' && !customProvider.trim()) { @@ -246,7 +295,8 @@ function ServerManager() { provider: '', tunnel: 'GRE', type: 'jumphost', - gateway: '' + gateway: '', + gateways: [makeGateway({ primary: true })], }); setCustomProvider(''); setAddServerModalOpen(false); @@ -264,7 +314,11 @@ function ServerManager() { }; const handleEditModalSave = (updatedServer) => { - const normalizedServer = { ...updatedServer, type: normalizeServerType(updatedServer.type) }; + const normalizedServer = { + ...updatedServer, + type: normalizeServerType(updatedServer.type), + gateways: NEEDS_GATEWAYS(updatedServer.type) ? normalizeGateways(updatedServer.gateways, updatedServer.gateway || updatedServer.dns || updatedServer.ip) : [], + }; setServers(servers.map(s => { const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false; const sameIp = s.ip === updatedServer.ip; @@ -1022,6 +1076,100 @@ function ServerManager() { required helpText="Имя основного шлюза для маршрутизации" /> + {NEEDS_GATEWAYS(newServer.type) && ( + <> +
Шлюзы
+
+ {newServer.gateways.map((gw, idx) => ( +
+
+
+ + { + const list = newServer.gateways.map((g, i) => i === idx ? { ...g, name: e.target.value } : g); + setNewServer({ ...newServer, gateways: list }); + }} + placeholder="GW-MSK" + required + /> +
+
+ + { + const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g); + setNewServer({ ...newServer, gateways: list }); + }} + placeholder="10.0.0.1" + /> +
+
+ + { + const list = newServer.gateways.map((g, i) => i === idx ? { ...g, comment: e.target.value } : g); + setNewServer({ ...newServer, gateways: list }); + }} + placeholder="Основной шлюз" + /> +
+
+
+ { + const list = newServer.gateways.map((g, i) => ({ ...g, primary: i === idx ? e.target.checked : false })); + if (!list.some(g => g.primary)) list[idx].primary = true; + setNewServer({ ...newServer, gateways: list }); + }} + title="Основной шлюз" + /> +
+
+
+
+
{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}
+ {newServer.gateways.length > 1 && ( + + )} +
+
+ ))} +
+ +
Для jumphost/exit должен быть один основной gateway.
+ + )}