feat(network-config-manager): implement OSPF candidate management and normalization for interface templates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
This commit is contained in:
@@ -125,6 +125,135 @@ const GATEWAY_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const OSPF_COST_STEP = 10;
|
||||||
|
|
||||||
|
function getServerRefMap(servers = []) {
|
||||||
|
const byRef = new Map();
|
||||||
|
(servers || []).forEach((server) => {
|
||||||
|
if (!server || typeof server !== 'object') return;
|
||||||
|
if (server.id) byRef.set(String(server.id), server);
|
||||||
|
if (server.ip) byRef.set(String(server.ip), server);
|
||||||
|
if (server.dns) byRef.set(String(server.dns), server);
|
||||||
|
});
|
||||||
|
return byRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHomeOspfCandidates(tunnelInterfaces = [], servers = []) {
|
||||||
|
const serverByRef = getServerRefMap(servers);
|
||||||
|
const candidatesByServer = {};
|
||||||
|
|
||||||
|
(tunnelInterfaces || []).forEach((iface) => {
|
||||||
|
if (String(iface?.type || '').toUpperCase() !== 'GRE') return;
|
||||||
|
const tunnelId = String(iface?.id || '');
|
||||||
|
|
||||||
|
const addCandidate = (serverRef, side) => {
|
||||||
|
const server = serverByRef.get(String(serverRef || ''));
|
||||||
|
if (!server || String(server.type || '').toLowerCase() !== 'home') return;
|
||||||
|
|
||||||
|
const serverId = String(server.id || serverRef || '');
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
const interfaceName = String(
|
||||||
|
side === 'left' ? (iface?.name || '') : (iface?.name2 || iface?.name || '')
|
||||||
|
).trim();
|
||||||
|
if (!interfaceName) return;
|
||||||
|
|
||||||
|
const uniqueKey = `${tunnelId}:${side}:${interfaceName}`;
|
||||||
|
if (!candidatesByServer[serverId]) candidatesByServer[serverId] = [];
|
||||||
|
candidatesByServer[serverId].push({
|
||||||
|
key: uniqueKey,
|
||||||
|
serverId,
|
||||||
|
tunnelInterfaceId: tunnelId,
|
||||||
|
interfaceName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addCandidate(iface?.serverId, 'left');
|
||||||
|
addCandidate(iface?.serverId2, 'right');
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(candidatesByServer).forEach((serverId) => {
|
||||||
|
const unique = new Map();
|
||||||
|
candidatesByServer[serverId].forEach((candidate) => {
|
||||||
|
if (!unique.has(candidate.key)) unique.set(candidate.key, candidate);
|
||||||
|
});
|
||||||
|
candidatesByServer[serverId] = Array.from(unique.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidatesByServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areOspfTemplateListsEqual(prevList = [], nextList = []) {
|
||||||
|
if (prevList.length !== nextList.length) return false;
|
||||||
|
for (let i = 0; i < prevList.length; i += 1) {
|
||||||
|
const prev = prevList[i];
|
||||||
|
const next = nextList[i];
|
||||||
|
if (
|
||||||
|
String(prev?.id || '') !== String(next?.id || '') ||
|
||||||
|
String(prev?.key || '') !== String(next?.key || '') ||
|
||||||
|
String(prev?.serverId || '') !== String(next?.serverId || '') ||
|
||||||
|
String(prev?.tunnelInterfaceId || '') !== String(next?.tunnelInterfaceId || '') ||
|
||||||
|
String(prev?.interfaceName || '') !== String(next?.interfaceName || '') ||
|
||||||
|
Number(prev?.cost || 0) !== Number(next?.cost || 0)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOspfTemplates(rawTemplates = [], candidatesByServer = {}) {
|
||||||
|
const normalized = [];
|
||||||
|
|
||||||
|
Object.entries(candidatesByServer).forEach(([serverId, candidates]) => {
|
||||||
|
const byKey = new Map(candidates.map((item) => [String(item.key), item]));
|
||||||
|
const existing = (rawTemplates || [])
|
||||||
|
.filter((item) => String(item?.serverId || '') === String(serverId) && byKey.has(String(item?.key || '')))
|
||||||
|
.sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0));
|
||||||
|
|
||||||
|
const usedKeys = new Set();
|
||||||
|
const ordered = [];
|
||||||
|
|
||||||
|
existing.forEach((item) => {
|
||||||
|
const key = String(item?.key || '');
|
||||||
|
if (usedKeys.has(key)) return;
|
||||||
|
usedKeys.add(key);
|
||||||
|
ordered.push({
|
||||||
|
...item,
|
||||||
|
...byKey.get(key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
if (usedKeys.has(candidate.key)) return;
|
||||||
|
usedKeys.add(candidate.key);
|
||||||
|
ordered.push({
|
||||||
|
id: `ospf-${serverId}-${candidate.key}`,
|
||||||
|
key: candidate.key,
|
||||||
|
serverId: candidate.serverId,
|
||||||
|
tunnelInterfaceId: candidate.tunnelInterfaceId,
|
||||||
|
interfaceName: candidate.interfaceName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const withCosts = ordered.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
cost: (ordered.length - idx) * OSPF_COST_STEP,
|
||||||
|
}));
|
||||||
|
normalized.push(...withCosts);
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveInArray(arr, fromIndex, toIndex) {
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
||||||
|
const copy = [...arr];
|
||||||
|
const [moved] = copy.splice(fromIndex, 1);
|
||||||
|
copy.splice(toIndex, 0, moved);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
// Пустые объекты
|
// Пустые объекты
|
||||||
const getEmptyGateway = () => ({
|
const getEmptyGateway = () => ({
|
||||||
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
||||||
@@ -163,6 +292,7 @@ const getDefaultConfig = () => ({
|
|||||||
gateways: [],
|
gateways: [],
|
||||||
tunnelInterfaces: [],
|
tunnelInterfaces: [],
|
||||||
ipPools: [],
|
ipPools: [],
|
||||||
|
ospfInterfaceTemplates: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
function NetworkConfigManager() {
|
function NetworkConfigManager() {
|
||||||
@@ -222,6 +352,7 @@ function NetworkConfigManager() {
|
|||||||
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
|
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
|
||||||
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
||||||
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
||||||
|
const [ospfDraggingItem, setOspfDraggingItem] = useState(null);
|
||||||
|
|
||||||
// === IPSec Passwords Management ===
|
// === IPSec Passwords Management ===
|
||||||
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
||||||
@@ -285,6 +416,7 @@ function NetworkConfigManager() {
|
|||||||
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
|
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
|
||||||
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
|
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
|
||||||
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
|
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
|
||||||
|
ospfInterfaceTemplates: Array.isArray(data.ospfInterfaceTemplates) ? data.ospfInterfaceTemplates : [],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status !== 404) {
|
if (error.response?.status !== 404) {
|
||||||
@@ -481,6 +613,78 @@ function NetworkConfigManager() {
|
|||||||
return result;
|
return result;
|
||||||
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
||||||
|
|
||||||
|
const homeOspfCandidatesByServer = useMemo(
|
||||||
|
() => getHomeOspfCandidates(config.tunnelInterfaces || [], servers),
|
||||||
|
[config.tunnelInterfaces, servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const prevTemplates = Array.isArray(prev?.ospfInterfaceTemplates) ? prev.ospfInterfaceTemplates : [];
|
||||||
|
const nextTemplates = normalizeOspfTemplates(prevTemplates, homeOspfCandidatesByServer);
|
||||||
|
if (areOspfTemplateListsEqual(prevTemplates, nextTemplates)) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
ospfInterfaceTemplates: nextTemplates,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [homeOspfCandidatesByServer]);
|
||||||
|
|
||||||
|
const ospfTemplatesByHomeServer = useMemo(() => {
|
||||||
|
const grouped = {};
|
||||||
|
(config.ospfInterfaceTemplates || []).forEach((item) => {
|
||||||
|
const sid = String(item?.serverId || '');
|
||||||
|
if (!sid) return;
|
||||||
|
if (!grouped[sid]) grouped[sid] = [];
|
||||||
|
grouped[sid].push(item);
|
||||||
|
});
|
||||||
|
Object.keys(grouped).forEach((sid) => {
|
||||||
|
grouped[sid].sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0));
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}, [config.ospfInterfaceTemplates]);
|
||||||
|
|
||||||
|
const ospfHomeServerIds = useMemo(() => {
|
||||||
|
let ids = Object.keys(homeOspfCandidatesByServer);
|
||||||
|
if (serverFilter) ids = ids.filter((sid) => String(sid) === String(serverFilter));
|
||||||
|
return ids;
|
||||||
|
}, [homeOspfCandidatesByServer, serverFilter]);
|
||||||
|
|
||||||
|
const handleDropOspfTemplate = (serverId, targetKey) => {
|
||||||
|
if (!ospfDraggingItem || String(ospfDraggingItem.serverId) !== String(serverId)) return;
|
||||||
|
const draggedKey = String(ospfDraggingItem.key || '');
|
||||||
|
const destinationKey = String(targetKey || '');
|
||||||
|
if (!draggedKey || !destinationKey || draggedKey === destinationKey) {
|
||||||
|
setOspfDraggingItem(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig((prev) => {
|
||||||
|
const current = (prev.ospfInterfaceTemplates || [])
|
||||||
|
.filter((item) => String(item.serverId || '') === String(serverId))
|
||||||
|
.sort((a, b) => Number(b?.cost || 0) - Number(a?.cost || 0));
|
||||||
|
const fromIndex = current.findIndex((item) => String(item.key || '') === draggedKey);
|
||||||
|
const toIndex = current.findIndex((item) => String(item.key || '') === destinationKey);
|
||||||
|
if (fromIndex < 0 || toIndex < 0) return prev;
|
||||||
|
|
||||||
|
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx, arr) => ({
|
||||||
|
...item,
|
||||||
|
cost: (arr.length - idx) * OSPF_COST_STEP,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const otherServers = (prev.ospfInterfaceTemplates || []).filter(
|
||||||
|
(item) => String(item.serverId || '') !== String(serverId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
ospfInterfaceTemplates: [...otherServers, ...reordered],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setOspfDraggingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
// === Группировка по серверам ===
|
// === Группировка по серверам ===
|
||||||
const gatewaysByServer = useMemo(() => {
|
const gatewaysByServer = useMemo(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
@@ -3665,6 +3869,73 @@ function NetworkConfigManager() {
|
|||||||
{/* Interfaces Tab */}
|
{/* Interfaces Tab */}
|
||||||
{activeTab === 'interfaces' && (
|
{activeTab === 'interfaces' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="card-title mb-0">OSPF Interface Template (HOME)</h3>
|
||||||
|
<div className="text-muted small">
|
||||||
|
Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge bg-blue-lt text-blue">
|
||||||
|
{config.ospfInterfaceTemplates?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{ospfHomeServerIds.length === 0 ? (
|
||||||
|
<div className="text-muted">
|
||||||
|
Для HOME роутеров пока нет GRE-интерфейсов. Создайте GRE-туннели, и шаблоны появятся автоматически.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
{ospfHomeServerIds.map((serverId) => {
|
||||||
|
const server = getServerInfo(serverId);
|
||||||
|
const templates = ospfTemplatesByHomeServer[serverId] || [];
|
||||||
|
return (
|
||||||
|
<div key={`ospf-home-${serverId}`} className="col-12 col-xl-6">
|
||||||
|
<div className="card card-sm border">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="fw-semibold">{server?.dns || server?.ip || serverId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="list-group-item text-muted small">
|
||||||
|
Нет доступных интерфейсов для OSPF шаблонов.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.key}
|
||||||
|
className="list-group-item d-flex align-items-center justify-content-between"
|
||||||
|
draggable
|
||||||
|
onDragStart={() =>
|
||||||
|
setOspfDraggingItem({ serverId, key: template.key })
|
||||||
|
}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => handleDropOspfTemplate(serverId, template.key)}
|
||||||
|
onDragEnd={() => setOspfDraggingItem(null)}
|
||||||
|
style={{ cursor: 'grab' }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="text-muted small">::</span>
|
||||||
|
<code>{template.interfaceName}</code>
|
||||||
|
</div>
|
||||||
|
<span className="badge bg-primary-lt text-primary">
|
||||||
|
cost {template.cost}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filteredInterfaces.length === 0 ? (
|
{filteredInterfaces.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-center py-5">
|
<div className="card-body text-center py-5">
|
||||||
|
|||||||
Reference in New Issue
Block a user