feat(route-optimizer): implement pinned community gateways feature with UI integration for settings and recommendations
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s

This commit is contained in:
2026-03-04 23:39:43 +07:00
parent 624c0d6fd3
commit 8012968ab5
3 changed files with 297 additions and 11 deletions
+69 -10
View File
@@ -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,
};
});
+11
View File
@@ -247,6 +247,11 @@ export default function RouteOptimizerPage() {
</td>
<td className="small">
<div>{r.recommended?.gateway || '—'}</div>
{r.pinnedBySettings && (
<div className="text-muted">
pin: {r.pinnedGateway || '—'}
</div>
)}
{r.recommended?.tunnelGateway && (
<div className="text-muted">
{'->'} {r.recommended.tunnelGateway}
@@ -266,6 +271,12 @@ export default function RouteOptimizerPage() {
<td>
{r.current == null && r.recommended == null ? (
<span className="badge bg-secondary-lt text-secondary">Недостаточно данных</span>
) : r.pinnedBySettings && !r.pinnedGatewayResolved ? (
<span className="badge bg-yellow-lt text-yellow">Pin задан, но gateway не найден</span>
) : r.pinnedBySettings && r.shouldSwitch ? (
<span className="badge bg-orange-lt text-orange">Принудительный switch (pin)</span>
) : r.pinnedBySettings ? (
<span className="badge bg-blue-lt text-blue">Зафиксировано (pin)</span>
) : r.current == null ? (
<span className="badge bg-orange-lt text-orange">Текущий gateway не сопоставлен</span>
) : r.shouldSwitch ? (
+217 -1
View File
@@ -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}
/>
</div>
<div className="col-12 mt-2"><h4 className="subheader">Жесткая привязка community к gateway (100%)</h4></div>
<div className="col-12">
<div className="text-muted small mb-2">
Если задана привязка, AI будет считать этот gateway обязательным для community на выбранном jumphost.
</div>
<div className="row g-2">
<div className="col-12 col-md-4">
<label className="form-label">Jumphost</label>
<select
className="form-select"
value={aiPinDraft.jumphost}
onChange={(e) => setAiPinDraft((prev) => ({ ...prev, jumphost: e.target.value }))}
disabled={saving}
>
<option value="">Любой jumphost</option>
{aiJumphostOptions.map((j) => (
<option key={j.value} value={j.value}>{j.label}</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Community</label>
<input
className="form-control"
list="ai-community-options"
value={aiPinDraft.community}
onChange={(e) => setAiPinDraft((prev) => ({ ...prev, community: e.target.value }))}
placeholder="Например, 65001:100"
disabled={saving}
/>
<datalist id="ai-community-options">
{communityValueOptions.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Gateway</label>
<select
className="form-select"
value={aiPinDraft.gateway}
onChange={(e) => setAiPinDraft((prev) => ({ ...prev, gateway: e.target.value }))}
disabled={saving}
>
<option value="">Выберите recursive gateway</option>
{aiGatewayOptionsForDraft.map((g) => (
<option key={`${g.value}-${g.ownerRef || ''}`} value={g.value}>{g.label}</option>
))}
</select>
</div>
<div className="col-12">
<button
type="button"
className="btn btn-outline-primary"
onClick={addPinnedCommunityGateway}
disabled={saving || !String(aiPinDraft.community || '').trim() || !String(aiPinDraft.gateway || '').trim()}
>
Добавить привязку
</button>
</div>
</div>
</div>
<div className="col-12">
<div className="table-responsive">
<table className="table table-sm table-vcenter card-table">
<thead>
<tr>
<th>Jumphost</th>
<th>Community</th>
<th>Gateway</th>
<th style={{ width: 120 }}>Действие</th>
</tr>
</thead>
<tbody>
{aiPinnedCommunityGateways.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">Привязки не добавлены</td>
</tr>
) : aiPinnedCommunityGateways.map((row, idx) => (
<tr key={`${row.jumphost || '*'}:${row.community}:${row.gateway}:${idx}`}>
<td className="small">{row.jumphost || 'Любой'}</td>
<td className="small fw-semibold">{row.community}</td>
<td className="small">{row.gateway}</td>
<td>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => removePinnedCommunityGateway(idx)}
disabled={saving}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
)}