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