feat(mikrotik-config): add OSPF interface templates management with loading and applying functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
This commit is contained in:
@@ -15,7 +15,7 @@ const {
|
|||||||
buildMikrotikRecursiveRoutes,
|
buildMikrotikRecursiveRoutes,
|
||||||
getParentGateway,
|
getParentGateway,
|
||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = require('../utils/mikrotikInterfaceGenerator');
|
||||||
const { createRosClient, applyBlock, rosPrint, rosAdd, rosRemove } = require('../services/mikrotikApplyService');
|
const { createRosClient, applyBlock, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json';
|
||||||
const NETWORK_CONFIG_KEY = 'network-config.json';
|
const NETWORK_CONFIG_KEY = 'network-config.json';
|
||||||
@@ -1474,6 +1474,203 @@ async function getAddressLists(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOspfInterfaceName(entry) {
|
||||||
|
if (!entry || typeof entry !== 'object') return '';
|
||||||
|
return String(entry.interfaces ?? entry.interface ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOspfCost(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOspfInterface(a, b) {
|
||||||
|
return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mikrotik/ospf-interface-templates?serverId=
|
||||||
|
* Возвращает текущие OSPF interface-template с HOME роутеров.
|
||||||
|
*/
|
||||||
|
async function getOspfInterfaceTemplates(req, res) {
|
||||||
|
try {
|
||||||
|
const requestedServerId = String(req.query?.serverId || '').trim();
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home');
|
||||||
|
|
||||||
|
const targets = requestedServerId
|
||||||
|
? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId))
|
||||||
|
: homeServers;
|
||||||
|
|
||||||
|
if (requestedServerId && targets.length === 0) {
|
||||||
|
return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const server of targets) {
|
||||||
|
const serverId = server.id || server.ip || server.dns;
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: 'MikroTik credentials not configured for this server',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const raw = await rosPrint(client, 'routing/ospf/interface-template');
|
||||||
|
const templates = (Array.isArray(raw) ? raw : [])
|
||||||
|
.map((item) => {
|
||||||
|
const interfaceName = normalizeOspfInterfaceName(item);
|
||||||
|
if (!interfaceName) return null;
|
||||||
|
return {
|
||||||
|
id: item['.id'] || null,
|
||||||
|
interfaceName,
|
||||||
|
cost: parseOspfCost(item.cost),
|
||||||
|
area: item.area || '',
|
||||||
|
networks: item.networks || '',
|
||||||
|
networkType: item['type'] || item['network-type'] || '',
|
||||||
|
disabled: String(item.disabled || '').toLowerCase() === 'true',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
templates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: errors.length === 0,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mikrotik/ospf-interface-templates/apply
|
||||||
|
* Body: { servers: [{ serverId, templates: [{ interfaceName, cost }] }] }
|
||||||
|
*/
|
||||||
|
async function applyOspfInterfaceTemplates(req, res) {
|
||||||
|
try {
|
||||||
|
const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : [];
|
||||||
|
if (payloadServers.length === 0) {
|
||||||
|
return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await readServersFromS3();
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 };
|
||||||
|
|
||||||
|
for (const payload of payloadServers) {
|
||||||
|
const serverRef = String(payload?.serverId || '').trim();
|
||||||
|
const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : [];
|
||||||
|
const server = servers.find((s) =>
|
||||||
|
[s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server || String(server.type || '').toLowerCase() !== 'home') {
|
||||||
|
errors.push({ serverId: serverRef, error: 'HOME server not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = server.id || server.ip || server.dns;
|
||||||
|
const creds = getMikrotikCredentials(server);
|
||||||
|
if (!creds) {
|
||||||
|
errors.push({ serverId, error: 'MikroTik credentials not configured for this server' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createRosClient(creds);
|
||||||
|
const raw = await rosPrint(client, 'routing/ospf/interface-template');
|
||||||
|
const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item));
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
|
||||||
|
for (const desired of desiredTemplates) {
|
||||||
|
const interfaceName = String(desired?.interfaceName || '').trim();
|
||||||
|
const desiredCost = parseOspfCost(desired?.cost);
|
||||||
|
|
||||||
|
if (!interfaceName || desiredCost == null) {
|
||||||
|
invalid += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = existing.find((item) =>
|
||||||
|
sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match || !match['.id']) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCost = parseOspfCost(match.cost);
|
||||||
|
if (currentCost === desiredCost) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) });
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.updated += updated;
|
||||||
|
summary.skipped += skipped;
|
||||||
|
summary.missing += missing;
|
||||||
|
summary.invalid += invalid;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
missing,
|
||||||
|
invalid,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
serverId,
|
||||||
|
serverLabel: server.dns || server.ip || serverId,
|
||||||
|
error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: errors.length === 0,
|
||||||
|
summary,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
|
const ADDRESS_LIST_PATH = 'ip/firewall/address-list';
|
||||||
const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100;
|
const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100;
|
||||||
const ADDRESS_LIST_ADD_CONCURRENCY = 8;
|
const ADDRESS_LIST_ADD_CONCURRENCY = 8;
|
||||||
@@ -1684,6 +1881,8 @@ module.exports = {
|
|||||||
speedTestViaTunnel,
|
speedTestViaTunnel,
|
||||||
loadNetworkConfig,
|
loadNetworkConfig,
|
||||||
getAddressLists,
|
getAddressLists,
|
||||||
|
getOspfInterfaceTemplates,
|
||||||
|
applyOspfInterfaceTemplates,
|
||||||
applyAddressListSummary,
|
applyAddressListSummary,
|
||||||
uptimeCheck,
|
uptimeCheck,
|
||||||
getUptimeCache,
|
getUptimeCache,
|
||||||
|
|||||||
@@ -473,6 +473,8 @@ app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTes
|
|||||||
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
|
||||||
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
app.get('/api/mikrotik/address-lists', mikrotikConfigRoutes.getAddressLists);
|
||||||
|
app.get('/api/mikrotik/ospf-interface-templates', mikrotikConfigRoutes.getOspfInterfaceTemplates);
|
||||||
|
app.post('/api/mikrotik/ospf-interface-templates/apply', writeLimiter, mikrotikConfigRoutes.applyOspfInterfaceTemplates);
|
||||||
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
app.post('/api/mikrotik/address-lists/apply-summary', writeLimiter, mikrotikConfigRoutes.applyAddressListSummary);
|
||||||
|
|
||||||
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
// === UPTIME MONITOR (проверка доступности: http / internal-ping / external-ping из настроек) ===
|
||||||
|
|||||||
@@ -233,19 +233,56 @@ function normalizeOspfTemplates(rawTemplates = [], candidatesByServer = {}) {
|
|||||||
serverId: candidate.serverId,
|
serverId: candidate.serverId,
|
||||||
tunnelInterfaceId: candidate.tunnelInterfaceId,
|
tunnelInterfaceId: candidate.tunnelInterfaceId,
|
||||||
interfaceName: candidate.interfaceName,
|
interfaceName: candidate.interfaceName,
|
||||||
|
cost: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const withCosts = ordered.map((item, idx) => ({
|
const existingCosts = ordered
|
||||||
...item,
|
.map((item) => Number(item?.cost))
|
||||||
cost: (ordered.length - idx) * OSPF_COST_STEP,
|
.filter((cost) => Number.isFinite(cost) && cost >= 0);
|
||||||
}));
|
let nextAutoCost = existingCosts.length > 0
|
||||||
normalized.push(...withCosts);
|
? Math.max(OSPF_COST_STEP, Math.min(...existingCosts) - OSPF_COST_STEP)
|
||||||
|
: Math.max(OSPF_COST_STEP, ordered.length * OSPF_COST_STEP);
|
||||||
|
|
||||||
|
ordered.forEach((item) => {
|
||||||
|
const numericCost = Number(item?.cost);
|
||||||
|
if (Number.isFinite(numericCost) && numericCost >= 0) {
|
||||||
|
normalized.push({ ...item, cost: numericCost });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalized.push({ ...item, cost: nextAutoCost });
|
||||||
|
nextAutoCost = Math.max(OSPF_COST_STEP, nextAutoCost - OSPF_COST_STEP);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeOspfCostsFromRouter(currentTemplates = [], routerResults = []) {
|
||||||
|
const routerCostMap = new Map();
|
||||||
|
|
||||||
|
(routerResults || []).forEach((serverResult) => {
|
||||||
|
const serverId = String(serverResult?.serverId || '').trim();
|
||||||
|
if (!serverId) return;
|
||||||
|
const templates = Array.isArray(serverResult?.templates) ? serverResult.templates : [];
|
||||||
|
templates.forEach((item) => {
|
||||||
|
const interfaceName = String(item?.interfaceName || '').trim();
|
||||||
|
const cost = Number(item?.cost);
|
||||||
|
if (!interfaceName || !Number.isFinite(cost) || cost < 0) return;
|
||||||
|
const key = `${serverId}::${interfaceName.toUpperCase()}`;
|
||||||
|
routerCostMap.set(key, cost);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (currentTemplates || []).map((item) => {
|
||||||
|
const serverId = String(item?.serverId || '').trim();
|
||||||
|
const interfaceName = String(item?.interfaceName || '').trim();
|
||||||
|
const lookup = `${serverId}::${interfaceName.toUpperCase()}`;
|
||||||
|
if (!routerCostMap.has(lookup)) return item;
|
||||||
|
return { ...item, cost: routerCostMap.get(lookup) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function moveInArray(arr, fromIndex, toIndex) {
|
function moveInArray(arr, fromIndex, toIndex) {
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
||||||
const copy = [...arr];
|
const copy = [...arr];
|
||||||
@@ -353,6 +390,8 @@ function NetworkConfigManager() {
|
|||||||
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
||||||
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
||||||
const [ospfDraggingItem, setOspfDraggingItem] = useState(null);
|
const [ospfDraggingItem, setOspfDraggingItem] = useState(null);
|
||||||
|
const [ospfLoading, setOspfLoading] = useState(false);
|
||||||
|
const [ospfSaving, setOspfSaving] = useState(false);
|
||||||
|
|
||||||
// === IPSec Passwords Management ===
|
// === IPSec Passwords Management ===
|
||||||
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
||||||
@@ -685,6 +724,73 @@ function NetworkConfigManager() {
|
|||||||
setOspfDraggingItem(null);
|
setOspfDraggingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoadOspfFromMikrotik = async () => {
|
||||||
|
setOspfLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/mikrotik/ospf-interface-templates');
|
||||||
|
const results = Array.isArray(response.data?.results) ? response.data.results : [];
|
||||||
|
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||||
|
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ospfInterfaceTemplates: mergeOspfCostsFromRouter(prev.ospfInterfaceTemplates || [], results),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
notify.success(`OSPF загружен с HOME роутеров: ${results.length}`);
|
||||||
|
} else {
|
||||||
|
notify.warning('Не удалось получить OSPF шаблоны ни с одного HOME роутера');
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
notify.warning(`Ошибки чтения OSPF: ${errors.length} роутер(ов)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading OSPF templates:', error);
|
||||||
|
notify.error('Не удалось загрузить OSPF Interface Template с MikroTik');
|
||||||
|
} finally {
|
||||||
|
setOspfLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOspfToMikrotik = async () => {
|
||||||
|
const serverEntries = Object.entries(ospfTemplatesByHomeServer);
|
||||||
|
if (serverEntries.length === 0) {
|
||||||
|
notify.warning('Нет OSPF шаблонов для сохранения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOspfSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
servers: serverEntries.map(([serverId, templates]) => ({
|
||||||
|
serverId,
|
||||||
|
templates: templates.map((item) => ({
|
||||||
|
interfaceName: item.interfaceName,
|
||||||
|
cost: item.cost,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
|
||||||
|
const summary = response.data?.summary || {};
|
||||||
|
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||||
|
notify.success(
|
||||||
|
`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}, не найдено ${summary.missing || 0}`
|
||||||
|
);
|
||||||
|
if (Number(summary.invalid || 0) > 0) {
|
||||||
|
notify.warning(`Некорректных записей OSPF: ${summary.invalid}`);
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
notify.warning(`Ошибки применения OSPF: ${errors.length} роутер(ов)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying OSPF templates:', error);
|
||||||
|
notify.error('Не удалось сохранить OSPF Interface Template в MikroTik');
|
||||||
|
} finally {
|
||||||
|
setOspfSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// === Группировка по серверам ===
|
// === Группировка по серверам ===
|
||||||
const gatewaysByServer = useMemo(() => {
|
const gatewaysByServer = useMemo(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
@@ -3877,9 +3983,31 @@ function NetworkConfigManager() {
|
|||||||
Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет.
|
Только для HOME роутеров. Перетаскивание меняет приоритет: чем выше cost, тем выше приоритет.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge bg-blue-lt text-blue">
|
<div className="d-flex align-items-center gap-2">
|
||||||
{config.ospfInterfaceTemplates?.length || 0}
|
<span className="badge bg-blue-lt text-blue">
|
||||||
</span>
|
{config.ospfInterfaceTemplates?.length || 0}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={handleLoadOspfFromMikrotik}
|
||||||
|
disabled={ospfLoading}
|
||||||
|
title="Загрузить актуальные OSPF Interface Template с HOME роутеров"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} className={`me-1 ${ospfLoading ? 'spin' : ''}`} />
|
||||||
|
Загрузить из MikroTik
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSaveOspfToMikrotik}
|
||||||
|
disabled={ospfSaving || (config.ospfInterfaceTemplates?.length || 0) === 0}
|
||||||
|
title="Применить cost из шаблона на HOME роутеры"
|
||||||
|
>
|
||||||
|
<IconDatabase size={14} className={`me-1 ${ospfSaving ? 'spin' : ''}`} />
|
||||||
|
Сохранить OSPF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{ospfHomeServerIds.length === 0 ? (
|
{ospfHomeServerIds.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user