diff --git a/backend/routes/routeOptimizerRoutes.js b/backend/routes/routeOptimizerRoutes.js index b94caf2..2a26b6f 100644 --- a/backend/routes/routeOptimizerRoutes.js +++ b/backend/routes/routeOptimizerRoutes.js @@ -26,6 +26,7 @@ const DEFAULT_AI_SETTINGS = { freshnessGoodSeconds: 600, freshnessFairSeconds: 1800, distancePenaltyPerStep: 0.03, + pinnedCommunityGateways: [], }; function asObject(v) { @@ -119,6 +120,32 @@ function compactServer(server) { }; } +function normalizeRef(value) { + return String(value || '').trim(); +} + +function buildServerRefSet(server) { + return new Set(collectServerRefs(server).map(normalizeRef).filter(Boolean)); +} + +function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community) { + const targetCommunity = normalizeRef(community); + if (!targetCommunity || !Array.isArray(pinnedCommunityGateways)) return null; + const jumphostRefs = buildServerRefSet(jumphost); + for (const entry of pinnedCommunityGateways) { + const entryCommunity = normalizeRef(entry?.community); + if (!entryCommunity || entryCommunity !== targetCommunity) continue; + const pinnedGateway = normalizeRef(entry?.gateway); + if (!pinnedGateway) continue; + const pinnedJumphost = normalizeRef(entry?.jumphost); + if (!pinnedJumphost) return { community: entryCommunity, gateway: pinnedGateway, jumphost: null }; + if (jumphostRefs.has(pinnedJumphost)) { + return { community: entryCommunity, gateway: pinnedGateway, jumphost: pinnedJumphost }; + } + } + return null; +} + async function loadCommunitiesIndex() { try { const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null); @@ -217,6 +244,16 @@ async function loadAiSettings() { ); const sum2 = hjW + jeW || 1; + const pinnedCommunityGateways = Array.isArray(src.pinnedCommunityGateways) + ? src.pinnedCommunityGateways + .map((x) => ({ + community: normalizeRef(x?.community), + gateway: normalizeRef(x?.gateway), + jumphost: normalizeRef(x?.jumphost), + })) + .filter((x) => x.community && x.gateway) + : []; + return { latencyWeight: latencyWeight / sum, bandwidthWeight: bandwidthWeight / sum, @@ -259,6 +296,7 @@ async function loadAiSettings() { 0, 1 ), + pinnedCommunityGateways, }; } catch (_) { return { ...DEFAULT_AI_SETTINGS }; @@ -546,23 +584,41 @@ function buildCommunityOptimization({ const recommendations = filters.map((f) => { const currentGateway = String(f.gateway || '').trim(); + const pinned = findPinnedCommunityGateway( + aiSettings.pinnedCommunityGateways, + jumphost, + f.community + ); const currentCandidate = recursiveByIp.get(currentGateway) || recursiveById.get(currentGateway) || gatewayBestMap.get(currentGateway) || null; - const recommendedCandidate = - (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || - candidates[0] || - null; + const pinnedCandidate = pinned + ? recursiveByIp.get(pinned.gateway) || + recursiveById.get(pinned.gateway) || + gatewayBestMap.get(pinned.gateway) || + null + : null; + const recommendedCandidate = pinned + ? pinnedCandidate + : (recursiveOptions.length > 0 ? recursiveOptions[0] : null) || candidates[0] || null; const communityInfo = communitiesIndex.get(String(f.community)) || null; - const shouldSwitch = Boolean( - recommendedCandidate && - currentCandidate && - (recommendedCandidate.recursiveGateway || recommendedCandidate.gatewayIpForJumphost) !== - (currentCandidate.recursiveGateway || currentCandidate.gatewayIpForJumphost) && - (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= aiSettings.minProbabilityGainForSwitch + const recommendedGatewayRef = normalizeRef( + recommendedCandidate?.recursiveGateway || recommendedCandidate?.gatewayIpForJumphost ); + const currentGatewayRef = normalizeRef( + currentCandidate?.recursiveGateway || currentCandidate?.gatewayIpForJumphost || currentGateway + ); + const shouldSwitch = pinned + ? Boolean(recommendedGatewayRef && recommendedGatewayRef !== currentGatewayRef) + : Boolean( + recommendedCandidate && + currentCandidate && + recommendedGatewayRef !== currentGatewayRef && + (recommendedCandidate.probabilityOptimal - currentCandidate.probabilityOptimal) >= + aiSettings.minProbabilityGainForSwitch + ); return { community: String(f.community), @@ -595,6 +651,9 @@ function buildCommunityOptimization({ pingMs: recommendedCandidate.pingMs, speedMbps: recommendedCandidate.speedMbps, } : null, + pinnedGateway: pinned?.gateway || null, + pinnedBySettings: Boolean(pinned), + pinnedGatewayResolved: Boolean(pinned && recommendedCandidate), shouldSwitch, }; }); diff --git a/frontend/src/RouteOptimizerPage.jsx b/frontend/src/RouteOptimizerPage.jsx index 84a9104..396c459 100644 --- a/frontend/src/RouteOptimizerPage.jsx +++ b/frontend/src/RouteOptimizerPage.jsx @@ -247,6 +247,11 @@ export default function RouteOptimizerPage() {
{r.recommended?.gateway || '—'}
+ {r.pinnedBySettings && ( +
+ pin: {r.pinnedGateway || '—'} +
+ )} {r.recommended?.tunnelGateway && (
{'->'} {r.recommended.tunnelGateway} @@ -266,6 +271,12 @@ export default function RouteOptimizerPage() { {r.current == null && r.recommended == null ? ( Недостаточно данных + ) : r.pinnedBySettings && !r.pinnedGatewayResolved ? ( + Pin задан, но gateway не найден + ) : r.pinnedBySettings && r.shouldSwitch ? ( + Принудительный switch (pin) + ) : r.pinnedBySettings ? ( + Зафиксировано (pin) ) : r.current == null ? ( Текущий gateway не сопоставлен ) : r.shouldSwitch ? ( diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index 07d98d1..14271d5 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -136,6 +136,10 @@ export default function SettingsPage() { const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120'); const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600'); const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800'); + const [aiPinnedCommunityGateways, setAiPinnedCommunityGateways] = useState([]); + const [aiPinDraft, setAiPinDraft] = useState({ jumphost: '', community: '', gateway: '' }); + const [networkConfigData, setNetworkConfigData] = useState({}); + const [communitiesList, setCommunitiesList] = useState([]); const [sidebarSearch, setSidebarSearch] = useState(''); const [activeSection, setActiveSection] = useState(() => { const hash = (typeof location.hash === 'string' && location.hash.slice(1)) || ''; @@ -151,6 +155,62 @@ export default function SettingsPage() { ); }, [serversList]); + const aiJumphostOptions = useMemo(() => ( + (serversList || []) + .filter((s) => String(s?.type || '').toLowerCase() === 'jumphost') + .map((s) => { + const key = String(s?.id || s?.dns || s?.ip || '').trim(); + return { + value: key, + label: String(s?.name || s?.dns || s?.ip || s?.id || key || 'jumphost'), + refs: [s?.id, s?.dns, s?.ip].filter(Boolean).map((x) => String(x).trim()), + }; + }) + .filter((x) => x.value) + .sort((a, b) => a.label.localeCompare(b.label)) + ), [serversList]); + + const aiRecursiveGatewayOptions = useMemo(() => { + const gateways = Array.isArray(networkConfigData?.gateways) ? networkConfigData.gateways : []; + const serverByRef = new Map(); + (serversList || []).forEach((s) => { + [s?.id, s?.dns, s?.ip].filter(Boolean).forEach((r) => serverByRef.set(String(r).trim(), s)); + }); + + return gateways + .filter((g) => g && String(g.type || '').toLowerCase() === 'recursive') + .map((g) => { + const gatewayRef = String(g.ip || g.id || '').trim(); + const ownerRef = String(g.serverId || '').trim(); + const owner = ownerRef ? serverByRef.get(ownerRef) : null; + const ownerLabel = owner + ? String(owner.name || owner.dns || owner.ip || owner.id || ownerRef) + : ownerRef; + return { + value: gatewayRef, + label: ownerLabel ? `${gatewayRef} (${ownerLabel})` : gatewayRef, + ownerRef, + }; + }) + .filter((x) => x.value); + }, [networkConfigData, serversList]); + + const aiGatewayOptionsForDraft = useMemo(() => { + const selectedJh = String(aiPinDraft.jumphost || '').trim(); + if (!selectedJh) return aiRecursiveGatewayOptions; + const jh = aiJumphostOptions.find((x) => x.value === selectedJh); + if (!jh) return aiRecursiveGatewayOptions; + const refs = new Set(jh.refs); + const filtered = aiRecursiveGatewayOptions.filter((g) => refs.has(String(g.ownerRef || '').trim())); + return filtered.length > 0 ? filtered : aiRecursiveGatewayOptions; + }, [aiPinDraft.jumphost, aiJumphostOptions, aiRecursiveGatewayOptions]); + + const communityValueOptions = useMemo(() => ( + (communitiesList || []) + .map((c) => String(c?.value || '').trim()) + .filter(Boolean) + ), [communitiesList]); + const sidebarGroupsFiltered = useMemo(() => { const q = (sidebarSearch || '').trim().toLowerCase(); if (!q) return SIDEBAR_GROUPS; @@ -250,10 +310,11 @@ export default function SettingsPage() { (async () => { try { - const [settingsRes, serversRes, networkRes] = await Promise.all([ + const [settingsRes, serversRes, networkRes, communitiesRes] = await Promise.all([ api.get('/ui-settings'), api.get('/servers').catch(() => ({ data: [] })), api.get('/network-config').catch(() => ({ data: null })), + api.get('/communities').catch(() => ({ data: [] })), ]); const data = settingsRes?.data || {}; setRawSettings(data); @@ -346,6 +407,7 @@ export default function SettingsPage() { // Построить список туннелей (как в NetworkMapDashboard / планировщике карты сети) const config = networkRes?.data || {}; + setNetworkConfigData(config); const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; const getServer = (serverId) => serversData.find( @@ -372,6 +434,13 @@ export default function SettingsPage() { }); setTunnelConnections(tunnelConns); + const rawCommunities = Array.isArray(communitiesRes?.data) + ? communitiesRes.data + : Array.isArray(communitiesRes?.data?.items) + ? communitiesRes.data.items + : []; + setCommunitiesList(rawCommunities); + const a = data?.alertSettings || {}; setAlertServerOffline(a.serverOffline?.enabled !== false); setAlertServerOfflineMinutes( @@ -507,6 +576,17 @@ export default function SettingsPage() { ? String(ai.freshnessFairSeconds) : '1800' ); + setAiPinnedCommunityGateways( + Array.isArray(ai?.pinnedCommunityGateways) + ? ai.pinnedCommunityGateways + .map((x) => ({ + jumphost: String(x?.jumphost || '').trim(), + community: String(x?.community || '').trim(), + gateway: String(x?.gateway || '').trim(), + })) + .filter((x) => x.community && x.gateway) + : [] + ); const e = settingsRes?.headers?.etag || settingsRes?.headers?.ETag || ''; @@ -583,6 +663,31 @@ export default function SettingsPage() { }); }; + const addPinnedCommunityGateway = useCallback(() => { + const jumphost = String(aiPinDraft.jumphost || '').trim(); + const community = String(aiPinDraft.community || '').trim(); + const gateway = String(aiPinDraft.gateway || '').trim(); + if (!community || !gateway) return; + setAiPinnedCommunityGateways((prev) => { + const next = Array.isArray(prev) ? [...prev] : []; + const idx = next.findIndex( + (x) => + String(x?.jumphost || '').trim() === jumphost && + String(x?.community || '').trim() === community + ); + if (idx >= 0) next[idx] = { jumphost, community, gateway }; + else next.push({ jumphost, community, gateway }); + return next; + }); + setAiPinDraft((prev) => ({ ...prev, community: '', gateway: '' })); + }, [aiPinDraft]); + + const removePinnedCommunityGateway = useCallback((index) => { + setAiPinnedCommunityGateways((prev) => + (Array.isArray(prev) ? prev : []).filter((_, i) => i !== index) + ); + }, []); + const onSave = async () => { setError(''); setSuccess(''); @@ -712,6 +817,16 @@ export default function SettingsPage() { 10, Math.min(86400, parseInt(aiFreshnessFairSeconds, 10) || 1800) ), + pinnedCommunityGateways: (Array.isArray(aiPinnedCommunityGateways) + ? aiPinnedCommunityGateways + : [] + ) + .map((x) => ({ + jumphost: String(x?.jumphost || '').trim(), + community: String(x?.community || '').trim(), + gateway: String(x?.gateway || '').trim(), + })) + .filter((x) => x.community && x.gateway), }, alertSettings: { serverOffline: { @@ -1626,6 +1741,107 @@ export default function SettingsPage() { max={86400} />
+ +

Жесткая привязка community к gateway (100%)

+
+
+ Если задана привязка, AI будет считать этот gateway обязательным для community на выбранном jumphost. +
+
+
+ + +
+
+ + setAiPinDraft((prev) => ({ ...prev, community: e.target.value }))} + placeholder="Например, 65001:100" + disabled={saving} + /> + + {communityValueOptions.map((c) => ( + +
+
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + {aiPinnedCommunityGateways.length === 0 ? ( + + + + ) : aiPinnedCommunityGateways.map((row, idx) => ( + + + + + + + ))} + +
JumphostCommunityGatewayДействие
Привязки не добавлены
{row.jumphost || 'Любой'}{row.community}{row.gateway} + +
+
+
)}