feat(MikrotikConfig): add API endpoint for applying MikroTik configuration; update frontend to support configuration application with dry-run option and modal interface
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:
@@ -99,6 +99,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d
|
|||||||
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
|
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
|
||||||
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
|
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
|
||||||
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
|
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
|
||||||
|
- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost.
|
||||||
|
|
||||||
### Прочее
|
### Прочее
|
||||||
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
} = require('../utils/mikrotikInterfaceGenerator');
|
} = require('../utils/mikrotikInterfaceGenerator');
|
||||||
const { readS3TextObject } = require('../services/s3Service');
|
const { readS3TextObject } = require('../services/s3Service');
|
||||||
const { RouterOSAPI } = require('node-routeros');
|
const { RouterOSAPI } = require('node-routeros');
|
||||||
|
const { applyBlock } = require('../services/mikrotikApplyService');
|
||||||
|
|
||||||
async function fetchJsonFromS3(key, defaultValue = null) {
|
async function fetchJsonFromS3(key, defaultValue = null) {
|
||||||
try {
|
try {
|
||||||
@@ -227,9 +228,106 @@ async function testMikrotikConnection(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mikrotik/apply
|
||||||
|
* Body: { serverId: string, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }
|
||||||
|
* Применяет конфигурацию (интерфейсы, маршруты) на MikroTik через RouterOS API.
|
||||||
|
* dryRun=true — только показать план, не выполнять.
|
||||||
|
*/
|
||||||
|
async function applyMikrotikConfig(req, res) {
|
||||||
|
try {
|
||||||
|
const { serverId, type = 'all', dryRun = true } = req.body || {};
|
||||||
|
if (!serverId) {
|
||||||
|
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await fetchJsonFromS3('servers.json', []);
|
||||||
|
const server = servers.find(s => s.id === serverId || s.dns === serverId || s.ip === serverId);
|
||||||
|
if (!server) {
|
||||||
|
return sendError(res, 404, 'Server not found', 'E_NOT_FOUND');
|
||||||
|
}
|
||||||
|
if (server.type !== 'jumphost') {
|
||||||
|
return sendError(res, 400, 'Only jumphost servers support apply via API', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
if (!server.encryptedMikrotikPassword) {
|
||||||
|
return sendError(res, 400, 'MikroTik password not configured. Add credentials in Server settings.', 'E_BAD_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
let password;
|
||||||
|
try {
|
||||||
|
password = decrypt(server.encryptedMikrotikPassword);
|
||||||
|
} catch (decErr) {
|
||||||
|
return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = server.mikrotikHost || server.ip || server.dns;
|
||||||
|
const port = parseInt(server.mikrotikPort || '8728', 10) || 8728;
|
||||||
|
const user = server.mikrotikUser || 'admin';
|
||||||
|
|
||||||
|
const config = await fetchJsonFromS3('network-config.json', { gateways: [], tunnelInterfaces: [] });
|
||||||
|
const passwordIds = (config.tunnelInterfaces || [])
|
||||||
|
.filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '')
|
||||||
|
.map(i => i.ipsecPasswordId.trim());
|
||||||
|
const passwordMap = await fetchIpsecPasswordMap(passwordIds);
|
||||||
|
|
||||||
|
const includeInterfaces = type === 'interfaces' || type === 'all';
|
||||||
|
const includeRecursive = type === 'recursive' || type === 'all';
|
||||||
|
const blocks = await buildMikrotikConfig(config, servers, passwordMap, {
|
||||||
|
format: 'json',
|
||||||
|
serverId,
|
||||||
|
includeInterfaces,
|
||||||
|
includeRecursive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const conn = new RouterOSAPI({
|
||||||
|
host: String(host),
|
||||||
|
user: String(user),
|
||||||
|
password: String(password),
|
||||||
|
port: Number(port) || 8728,
|
||||||
|
});
|
||||||
|
|
||||||
|
await conn.connect();
|
||||||
|
|
||||||
|
const allResults = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
const blockResults = await applyBlock(conn, block, !!dryRun);
|
||||||
|
allResults.push({
|
||||||
|
blockType: block.type,
|
||||||
|
serverName: block.serverName,
|
||||||
|
results: blockResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.close();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
created: allResults.flatMap(b => b.results).filter(r => r.status === 'created' || r.status === 'would_create').length,
|
||||||
|
updated: allResults.flatMap(b => b.results).filter(r => r.status === 'updated' || r.status === 'would_update').length,
|
||||||
|
skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length,
|
||||||
|
errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
dryRun: !!dryRun,
|
||||||
|
summary,
|
||||||
|
results: allResults,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying MikroTik config:', error);
|
||||||
|
const msg = error.message || String(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено' :
|
||||||
|
msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateMikrotikConfig,
|
generateMikrotikConfig,
|
||||||
generateInterfaces,
|
generateInterfaces,
|
||||||
generateRecursiveRoutes,
|
generateRecursiveRoutes,
|
||||||
testMikrotikConnection,
|
testMikrotikConnection,
|
||||||
|
applyMikrotikConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig);
|
|||||||
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces);
|
||||||
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes);
|
||||||
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
|
app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection);
|
||||||
|
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
|
||||||
|
|
||||||
// === MIKROTIK VALIDATION ===
|
// === MIKROTIK VALIDATION ===
|
||||||
app.post('/api/mikrotik/validate', async (req, res) => {
|
app.post('/api/mikrotik/validate', async (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Сервис применения конфигурации MikroTik через RouterOS API
|
||||||
|
* Идемпотентная логика: create / update / skip
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { RouterOSAPI } = require('node-routeros');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразование params в массив для node-routeros: ['=key=value', ...]
|
||||||
|
*/
|
||||||
|
function paramsToRosArray(params) {
|
||||||
|
if (!params || typeof params !== 'object') return [];
|
||||||
|
return Object.entries(params)
|
||||||
|
.filter(([, v]) => v != null && v !== '')
|
||||||
|
.map(([k, v]) => `=${k}=${String(v)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить print с фильтром
|
||||||
|
* @param {object} conn - RouterOSAPI instance
|
||||||
|
* @param {string} path - e.g. '/interface/gre'
|
||||||
|
* @param {object} filter - e.g. { name: 'gre1' }
|
||||||
|
*/
|
||||||
|
async function rosPrint(conn, path, filter = {}) {
|
||||||
|
const pathClean = path.replace(/^\//, '').replace(/\//g, '/');
|
||||||
|
const fullPath = `/${pathClean}/print`;
|
||||||
|
const args = Object.entries(filter)
|
||||||
|
.filter(([, v]) => v != null && v !== '')
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const key = k.startsWith('~') ? k.slice(1) : k;
|
||||||
|
const prefix = k.startsWith('~') ? '?~' : '?';
|
||||||
|
return `${prefix}${key}=${String(v)}`;
|
||||||
|
});
|
||||||
|
const result = args.length > 0 ? await conn.write(fullPath, args) : await conn.write(fullPath);
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить add
|
||||||
|
*/
|
||||||
|
async function rosAdd(conn, path, params) {
|
||||||
|
const pathClean = path.replace(/^\//, '').replace(/\//g, '/');
|
||||||
|
const fullPath = `/${pathClean}/add`;
|
||||||
|
const args = paramsToRosArray(params);
|
||||||
|
return conn.write(fullPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить set
|
||||||
|
*/
|
||||||
|
async function rosSet(conn, path, id, params) {
|
||||||
|
const pathClean = path.replace(/^\//, '').replace(/\//g, '/');
|
||||||
|
const fullPath = `/${pathClean}/set`;
|
||||||
|
const args = [`.id=${id}`, ...paramsToRosArray(params)];
|
||||||
|
return conn.write(fullPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сравнить объект из RouterOS с желаемыми params (только ключевые поля)
|
||||||
|
*/
|
||||||
|
function paramsMatch(rosItem, params, keysToCompare) {
|
||||||
|
if (!rosItem || !params) return false;
|
||||||
|
for (const k of keysToCompare) {
|
||||||
|
const rosVal = rosItem[k];
|
||||||
|
const wantVal = params[k];
|
||||||
|
if (wantVal == null) continue;
|
||||||
|
if (String(rosVal || '').trim() !== String(wantVal || '').trim()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить одну операцию с idempotent логикой
|
||||||
|
*/
|
||||||
|
async function applyOperation(conn, op, dryRun) {
|
||||||
|
const { path, action, params, meta } = op;
|
||||||
|
const result = { path, action, params: { ...params }, status: null, details: null, error: null };
|
||||||
|
|
||||||
|
if (path === '/interface/list' && action === 'add' && meta?.ensureExists) {
|
||||||
|
const existing = await rosPrint(conn, '/interface/list', { name: params.name });
|
||||||
|
if (existing.length > 0) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = 'Interface list already exists';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_create';
|
||||||
|
result.details = `Would create interface list ${params.name}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosAdd(conn, '/interface/list', params);
|
||||||
|
result.status = 'created';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/interface/gre' && action === 'add') {
|
||||||
|
const name = params.name;
|
||||||
|
const existing = await rosPrint(conn, '/interface/gre', { name });
|
||||||
|
const compareKeys = ['remote-address', 'local-address', 'mtu', 'ipsec-secret', 'keepalive', 'allow-fast-path'];
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const match = paramsMatch(existing[0], params, compareKeys);
|
||||||
|
if (match) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = `Interface ${name} already configured`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_update';
|
||||||
|
result.details = `Would update interface ${name}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosSet(conn, '/interface/gre', existing[0]['.id'], params);
|
||||||
|
result.status = 'updated';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_create';
|
||||||
|
result.details = `Would create GRE interface ${name}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosAdd(conn, '/interface/gre', params);
|
||||||
|
result.status = 'created';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/interface/list/member' && action === 'add') {
|
||||||
|
const iface = params.interface;
|
||||||
|
const list = params.list;
|
||||||
|
const existing = await rosPrint(conn, '/interface/list/member', { list, interface: iface });
|
||||||
|
if (existing.length > 0) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = `Member ${iface} already in list ${list}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_create';
|
||||||
|
result.details = `Would add ${iface} to list ${list}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosAdd(conn, '/interface/list/member', params);
|
||||||
|
result.status = 'created';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/ip/address' && action === 'add') {
|
||||||
|
const addr = params.address;
|
||||||
|
const iface = params.interface;
|
||||||
|
const existing = await rosPrint(conn, '/ip/address', { interface: iface });
|
||||||
|
const match = existing.find(e => (e.address || '').startsWith(addr.split('/')[0]));
|
||||||
|
if (match) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = `Address ${addr} already on ${iface}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_create';
|
||||||
|
result.details = `Would add ${addr} to ${iface}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosAdd(conn, '/ip/address', params);
|
||||||
|
result.status = 'created';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/ip/route') {
|
||||||
|
if (action === 'remove' && meta?.findComment) {
|
||||||
|
const existing = await rosPrint(conn, '/ip/route', { '~comment': meta.findComment });
|
||||||
|
const toRemove = existing;
|
||||||
|
if (toRemove.length === 0) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = 'No matching routes to remove';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_remove';
|
||||||
|
result.details = `Would remove ${toRemove.length} route(s)`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (const r of toRemove) {
|
||||||
|
await conn.write('/ip/route/remove', [`.id=${r['.id']}`]);
|
||||||
|
}
|
||||||
|
result.status = 'removed';
|
||||||
|
result.details = `${toRemove.length} route(s) removed`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (action === 'add') {
|
||||||
|
const dst = params['dst-address'];
|
||||||
|
const gw = params.gateway;
|
||||||
|
const existing = await rosPrint(conn, '/ip/route', { 'dst-address': dst });
|
||||||
|
const match = existing.find(e => (e.gateway || '').includes((gw || '').split('%')[0]));
|
||||||
|
if (match) {
|
||||||
|
result.status = 'skip';
|
||||||
|
result.details = `Route to ${dst} already exists`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
result.status = 'would_create';
|
||||||
|
result.details = `Would add route ${dst} via ${gw}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await rosAdd(conn, '/ip/route', params);
|
||||||
|
result.status = 'created';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.status = 'skipped';
|
||||||
|
result.details = `Unsupported operation: ${path} ${action}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить блок операций к MikroTik
|
||||||
|
*/
|
||||||
|
async function applyBlock(conn, block, dryRun) {
|
||||||
|
const results = [];
|
||||||
|
const ops = block.operations || [];
|
||||||
|
for (const op of ops) {
|
||||||
|
try {
|
||||||
|
const r = await applyOperation(conn, op, dryRun);
|
||||||
|
results.push(r);
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
path: op.path,
|
||||||
|
action: op.action,
|
||||||
|
params: op.params,
|
||||||
|
status: 'error',
|
||||||
|
error: err.message || String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
paramsToRosArray,
|
||||||
|
rosPrint,
|
||||||
|
rosAdd,
|
||||||
|
rosSet,
|
||||||
|
applyOperation,
|
||||||
|
applyBlock,
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
IconList,
|
IconList,
|
||||||
IconCode,
|
IconCode,
|
||||||
|
IconPlug,
|
||||||
IconArrowsRightLeft,
|
IconArrowsRightLeft,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
@@ -231,6 +232,11 @@ function NetworkConfigManager() {
|
|||||||
// === MikroTik Code Generation ===
|
// === MikroTik Code Generation ===
|
||||||
const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false);
|
const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false);
|
||||||
const [generatedMikrotikCode, setGeneratedMikrotikCode] = useState([]);
|
const [generatedMikrotikCode, setGeneratedMikrotikCode] = useState([]);
|
||||||
|
const [applyModalOpen, setApplyModalOpen] = useState(false);
|
||||||
|
const [applyServerId, setApplyServerId] = useState(null);
|
||||||
|
const [applyDryRun, setApplyDryRun] = useState(true);
|
||||||
|
const [applyLoading, setApplyLoading] = useState(false);
|
||||||
|
const [applyResult, setApplyResult] = useState(null);
|
||||||
|
|
||||||
// === Глобальные настройки PTR зоны ===
|
// === Глобальные настройки PTR зоны ===
|
||||||
const [globalPtrZoneReplaceFrom, setGlobalPtrZoneReplaceFrom] = useState('');
|
const [globalPtrZoneReplaceFrom, setGlobalPtrZoneReplaceFrom] = useState('');
|
||||||
@@ -2215,6 +2221,40 @@ function NetworkConfigManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// === Применить конфигурацию по API MikroTik ===
|
||||||
|
const handleApplyMikrotikViaApi = async (dryRunOverride = null) => {
|
||||||
|
if (!applyServerId) return;
|
||||||
|
const useDryRun = dryRunOverride !== null ? dryRunOverride : applyDryRun;
|
||||||
|
setApplyLoading(true);
|
||||||
|
if (dryRunOverride !== null) setApplyResult(null);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/mikrotik/apply', {
|
||||||
|
serverId: applyServerId,
|
||||||
|
type: 'all',
|
||||||
|
dryRun: useDryRun,
|
||||||
|
});
|
||||||
|
setApplyResult(data);
|
||||||
|
if (data.ok && !useDryRun) {
|
||||||
|
notify.success(`Применено: создано ${data.summary?.created || 0}, обновлено ${data.summary?.updated || 0}, пропущено ${data.summary?.skipped || 0}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApplyResult({
|
||||||
|
ok: false,
|
||||||
|
error: error.response?.data?.error || error.message || 'Ошибка применения',
|
||||||
|
});
|
||||||
|
notify.error('Не удалось применить конфигурацию по API');
|
||||||
|
} finally {
|
||||||
|
setApplyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApplyModal = (serverId) => {
|
||||||
|
setApplyServerId(serverId);
|
||||||
|
setApplyResult(null);
|
||||||
|
setApplyDryRun(true);
|
||||||
|
setApplyModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// === Генерация кода MikroTik только для одного интерфейса ===
|
// === Генерация кода MikroTik только для одного интерфейса ===
|
||||||
const handleGenerateMikrotikCodeForInterface = async (iface) => {
|
const handleGenerateMikrotikCodeForInterface = async (iface) => {
|
||||||
if (!iface) {
|
if (!iface) {
|
||||||
@@ -2641,18 +2681,34 @@ function NetworkConfigManager() {
|
|||||||
{items.length} {type === 'gateway' ? (items.length === 1 ? 'gateway' : 'gateways') : (items.length === 1 ? 'интерфейс' : 'интерфейсов')}
|
{items.length} {type === 'gateway' ? (items.length === 1 ? 'gateway' : 'gateways') : (items.length === 1 ? 'интерфейс' : 'интерфейсов')}
|
||||||
</span>
|
</span>
|
||||||
{!isUnassigned && items.length > 0 && (
|
{!isUnassigned && items.length > 0 && (
|
||||||
<button
|
<>
|
||||||
className="btn btn-primary btn-sm flex-shrink-0"
|
<button
|
||||||
onClick={(e) => {
|
className="btn btn-primary btn-sm flex-shrink-0"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleGenerateMikrotikCodeForServer(serverId, type);
|
e.stopPropagation();
|
||||||
}}
|
handleGenerateMikrotikCodeForServer(serverId, type);
|
||||||
title={`Получить код MikroTik для всех ${type === 'gateway' ? 'gateways' : 'интерфейсов'} этого сервера`}
|
}}
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
title={`Получить код MikroTik для всех ${type === 'gateway' ? 'gateways' : 'интерфейсов'} этого сервера`}
|
||||||
>
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
<IconCode size={16} className="me-1" />
|
>
|
||||||
Код для MikroTik
|
<IconCode size={16} className="me-1" />
|
||||||
</button>
|
Код для MikroTik
|
||||||
|
</button>
|
||||||
|
{server?.type === 'jumphost' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-success btn-sm flex-shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openApplyModal(serverId);
|
||||||
|
}}
|
||||||
|
title="Применить конфигурацию на MikroTik по API"
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
<IconPlug size={16} className="me-1" />
|
||||||
|
Применить по API
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5230,6 +5286,119 @@ function NetworkConfigManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Apply via API Modal */}
|
||||||
|
{applyModalOpen && (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title d-flex align-items-center gap-2">
|
||||||
|
<IconPlug size={20} />
|
||||||
|
Применить конфигурацию на MikroTik по API
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={() => setApplyModalOpen(false)}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{!applyResult ? (
|
||||||
|
<>
|
||||||
|
<div className="form-check mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="applyDryRun"
|
||||||
|
checked={applyDryRun}
|
||||||
|
onChange={(e) => setApplyDryRun(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="applyDryRun">
|
||||||
|
Dry-run — только показать план, не выполнять изменения
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted small mb-0">
|
||||||
|
Будет применена конфигурация интерфейсов (GRE, IP) и рекурсивных маршрутов для выбранного сервера.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{applyResult.ok ? (
|
||||||
|
<>
|
||||||
|
<div className={`alert alert-${applyResult.dryRun ? 'info' : 'success'} mb-3`}>
|
||||||
|
{applyResult.dryRun ? (
|
||||||
|
<>Режим dry-run: показан план изменений (ничего не применено)</>
|
||||||
|
) : (
|
||||||
|
<>Конфигурация успешно применена</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<strong>Итого:</strong>{' '}
|
||||||
|
создано: {applyResult.summary?.created ?? 0}, обновлено: {applyResult.summary?.updated ?? 0}, пропущено: {applyResult.summary?.skipped ?? 0}
|
||||||
|
{applyResult.summary?.errors?.length > 0 && (
|
||||||
|
<span className="text-danger ms-2">, ошибок: {applyResult.summary.errors.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{applyResult.results?.map((block, bi) => (
|
||||||
|
<div key={bi} className="border rounded p-2 mb-2">
|
||||||
|
<div className="fw-medium mb-2">{block.serverName} — {block.blockType}</div>
|
||||||
|
<ul className="list-unstyled mb-0 small">
|
||||||
|
{block.results?.slice(0, 15).map((r, ri) => (
|
||||||
|
<li key={ri} className="text-muted">
|
||||||
|
{r.status === 'created' || r.status === 'would_create' ? '✓ ' : ''}
|
||||||
|
{r.status === 'updated' || r.status === 'would_update' ? '↻ ' : ''}
|
||||||
|
{r.status === 'skip' ? '○ ' : ''}
|
||||||
|
{r.status === 'error' ? '✗ ' : ''}
|
||||||
|
{r.details || r.error || `${r.path} ${r.action}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(block.results?.length || 0) > 15 && (
|
||||||
|
<li className="text-muted">... и ещё {block.results.length - 15}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-danger mb-0">{applyResult.error}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
{!applyResult ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setApplyModalOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={handleApplyMikrotikViaApi}
|
||||||
|
disabled={applyLoading}
|
||||||
|
>
|
||||||
|
{applyLoading ? 'Выполнение...' : (applyDryRun ? 'Показать план' : 'Применить')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{applyResult.dryRun && applyResult.ok && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-success me-2"
|
||||||
|
onClick={() => handleApplyMikrotikViaApi(false)}
|
||||||
|
disabled={applyLoading}
|
||||||
|
>
|
||||||
|
Применить по-настоящему
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setApplyModalOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Gateway Template Modal */}
|
{/* Gateway Template Modal */}
|
||||||
<FormModal
|
<FormModal
|
||||||
show={gatewayTemplateModalOpen}
|
show={gatewayTemplateModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user