feat(NetworkMapScheduler): integrate network map scheduler functionality with API routes and frontend components for enhanced network monitoring
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m53s
This commit is contained in:
@@ -1155,4 +1155,5 @@ module.exports = {
|
|||||||
pingViaInterface,
|
pingViaInterface,
|
||||||
runPingViaRouter,
|
runPingViaRouter,
|
||||||
speedTestViaTunnel,
|
speedTestViaTunnel,
|
||||||
|
loadNetworkConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Роуты планировщика карты сети: настройки, логи, ручной запуск, кеш.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
|
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||||
|
const networkMapScheduler = require('../services/networkMapScheduler');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
|
||||||
|
const SCHEDULER_KEYS = [
|
||||||
|
'networkMapSchedulerEnabled',
|
||||||
|
'networkMapSchedulerIntervalMinutes',
|
||||||
|
'networkMapSchedulerRunPing',
|
||||||
|
'networkMapSchedulerRunSpeedTest',
|
||||||
|
'networkMapSchedulerLogMaxLines',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function readUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/scheduler/network-map/settings */
|
||||||
|
async function getNetworkMapSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
return res.json(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/scheduler/network-map/settings — обновить только ключи планировщика в ui-settings */
|
||||||
|
async function patchNetworkMapSchedulerSettings(req, res) {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const current = await readUiSettings();
|
||||||
|
for (const key of SCHEDULER_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||||
|
current[key] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'networkMapSchedulerLogMaxLines')) {
|
||||||
|
networkMapScheduler.setLogMaxLines(body.networkMapSchedulerLogMaxLines);
|
||||||
|
}
|
||||||
|
await writeS3JsonObject(UI_SETTINGS_KEY, current);
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(current);
|
||||||
|
return res.json(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] patchSettings', e);
|
||||||
|
return sendError(res, 500, 'Не удалось сохранить настройки', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/scheduler/network-map/logs */
|
||||||
|
async function getNetworkMapSchedulerLogs(req, res) {
|
||||||
|
try {
|
||||||
|
const lines = networkMapScheduler.getLogs();
|
||||||
|
return res.json({ lines });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] getLogs', e);
|
||||||
|
return sendError(res, 500, 'Не удалось прочитать логи', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/scheduler/network-map/run-now — один запуск по текущим настройкам */
|
||||||
|
async function runNetworkMapSchedulerNow(req, res) {
|
||||||
|
try {
|
||||||
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const ui = await readUiSettings();
|
||||||
|
const settings = networkMapScheduler.getSettingsFromUiSettings(ui);
|
||||||
|
|
||||||
|
networkMapScheduler.runJob({
|
||||||
|
baseUrl,
|
||||||
|
runPing: settings.runPing,
|
||||||
|
runSpeedTest: settings.runSpeedTest,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[scheduler] run-now failed', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendOk(res, { message: 'Запуск выполняется в фоне' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[scheduler] run-now', e);
|
||||||
|
return sendError(res, 500, 'Не удалось запустить', 'E_SCHEDULER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/network-map-cache — кеш пингов и скоростей для карты сети */
|
||||||
|
async function getNetworkMapCache(req, res) {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(NETWORK_MAP_CACHE_KEY).catch(() => null);
|
||||||
|
if (!data?.body) {
|
||||||
|
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(data.body);
|
||||||
|
return res.json({
|
||||||
|
pingMap: parsed.pingMap && typeof parsed.pingMap === 'object' ? parsed.pingMap : {},
|
||||||
|
speedMap: parsed.speedMap && typeof parsed.speedMap === 'object' ? parsed.speedMap : {},
|
||||||
|
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[network-map-cache] get', e);
|
||||||
|
return res.json({ pingMap: {}, speedMap: {}, updatedAt: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getNetworkMapSchedulerSettings,
|
||||||
|
patchNetworkMapSchedulerSettings,
|
||||||
|
getNetworkMapSchedulerLogs,
|
||||||
|
runNetworkMapSchedulerNow,
|
||||||
|
getNetworkMapCache,
|
||||||
|
};
|
||||||
|
<|tool▁calls▁begin|><|tool▁call▁begin|>
|
||||||
|
StrReplace
|
||||||
+21
-7
@@ -31,6 +31,8 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
|
|||||||
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
||||||
const trafficRoutes = require('./routes/trafficRoutes');
|
const trafficRoutes = require('./routes/trafficRoutes');
|
||||||
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
||||||
|
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
||||||
|
const schedulerRoutes = require('./routes/schedulerRoutes');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
@@ -476,6 +478,15 @@ app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
|||||||
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
|
app.post('/api/mikrotik/backups/diff', mikrotikBackupRoutes.diffBackups);
|
||||||
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
|
app.post('/api/mikrotik/backups/run', writeLimiter, mikrotikBackupRoutes.runBackupNow);
|
||||||
|
|
||||||
|
// === NETWORK MAP CACHE (для быстрой загрузки карты сети) ===
|
||||||
|
app.get('/api/network-map-cache', schedulerRoutes.getNetworkMapCache);
|
||||||
|
|
||||||
|
// === SCHEDULER (карта сети: пинг и скорость по расписанию) ===
|
||||||
|
app.get('/api/scheduler/network-map/settings', schedulerRoutes.getNetworkMapSchedulerSettings);
|
||||||
|
app.patch('/api/scheduler/network-map/settings', writeLimiter, schedulerRoutes.patchNetworkMapSchedulerSettings);
|
||||||
|
app.get('/api/scheduler/network-map/logs', schedulerRoutes.getNetworkMapSchedulerLogs);
|
||||||
|
app.post('/api/scheduler/network-map/run-now', writeLimiter, schedulerRoutes.runNetworkMapSchedulerNow);
|
||||||
|
|
||||||
// === MIKROTIK VALIDATION ===
|
// === MIKROTIK VALIDATION ===
|
||||||
app.post('/api/mikrotik/validate', async (req, res) => {
|
app.post('/api/mikrotik/validate', async (req, res) => {
|
||||||
const { config } = req.body;
|
const { config } = req.body;
|
||||||
@@ -503,12 +514,15 @@ app.use(errorHandler);
|
|||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
console.log(`Server is running on http://localhost:${port}`);
|
||||||
|
try {
|
||||||
|
initMikrotikBackupScheduler(logger);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
initNetworkMapScheduler(logger, port);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ component: 'network-map-scheduler', err: e && e.message }, 'Failed to start network map scheduler');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запускаем планировщик автоматического бэкапа MikroTik (если включен через ENV)
|
|
||||||
try {
|
|
||||||
initMikrotikBackupScheduler(logger);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error({ component: 'mikrotik-backup', err: e && e.message }, 'Failed to start backup scheduler');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Планировщик пингов и замеров скорости для карты сети.
|
||||||
|
* Периодически вызывает пинг и speed-test по связям из network-config,
|
||||||
|
* сохраняет результаты в S3 (кеш по рёбрам + агрегат network-map-cache/latest.json).
|
||||||
|
* Логи пишет в кольцевой буфер для просмотра в UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { readServersFromS3 } = require('../routes/serversRoutes');
|
||||||
|
const { loadNetworkConfig } = require('../routes/mikrotikConfigRoutes');
|
||||||
|
const { readS3TextObject, writeS3JsonObject } = require('./s3Service');
|
||||||
|
|
||||||
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
||||||
|
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 5;
|
||||||
|
const DEFAULT_LOG_MAX_LINES = 500;
|
||||||
|
|
||||||
|
/** Кольцевой буфер логов */
|
||||||
|
class LogBuffer {
|
||||||
|
constructor(maxLines = 500) {
|
||||||
|
this.maxLines = Math.max(100, maxLines);
|
||||||
|
this.lines = [];
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(line) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const entry = `${ts} ${line}`;
|
||||||
|
if (this.lines.length < this.maxLines) {
|
||||||
|
this.lines.push(entry);
|
||||||
|
} else {
|
||||||
|
this.lines[this.index] = entry;
|
||||||
|
this.index = (this.index + 1) % this.maxLines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLines() {
|
||||||
|
if (this.lines.length < this.maxLines) return [...this.lines];
|
||||||
|
return [...this.lines.slice(this.index), ...this.lines.slice(0, this.index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaxLines(n) {
|
||||||
|
this.maxLines = Math.max(100, n);
|
||||||
|
if (this.lines.length > this.maxLines) {
|
||||||
|
this.lines = this.getLines().slice(-this.maxLines);
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logBuffer = new LogBuffer(DEFAULT_LOG_MAX_LINES);
|
||||||
|
|
||||||
|
async function loadUiSettings() {
|
||||||
|
try {
|
||||||
|
const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
const parsed = JSON.parse(data?.body || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Построить список связей как в NetworkMapDashboard (tunnelInterfaces → connections) */
|
||||||
|
async function buildConnections() {
|
||||||
|
const [serversList, config, uiSettings] = await Promise.all([
|
||||||
|
readServersFromS3(),
|
||||||
|
loadNetworkConfig(),
|
||||||
|
loadUiSettings(),
|
||||||
|
]);
|
||||||
|
const servers = Array.isArray(serversList) ? serversList : [];
|
||||||
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
|
||||||
|
const getServer = (serverId) =>
|
||||||
|
servers.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
||||||
|
|
||||||
|
const connList = [];
|
||||||
|
for (const iface of tunnelInterfaces) {
|
||||||
|
if (!iface.serverId || !iface.serverId2) continue;
|
||||||
|
const s1 = getServer(iface.serverId);
|
||||||
|
const s2 = getServer(iface.serverId2);
|
||||||
|
if (!s1 || !s2 || s1.ip === s2.ip) continue;
|
||||||
|
const s1Key = s1.id || s1.dns || s1.ip;
|
||||||
|
const s2Key = s2.id || s2.dns || s2.ip;
|
||||||
|
if (!s1Key || !s2Key) continue;
|
||||||
|
const isJumphost1 = s1 && ['jumphost', 'home'].includes(String(s1.type || '').toLowerCase());
|
||||||
|
const isJumphost2 = s2 && ['jumphost', 'home'].includes(String(s2.type || '').toLowerCase());
|
||||||
|
connList.push({
|
||||||
|
from: s1.ip,
|
||||||
|
to: s2.ip,
|
||||||
|
fromKey: s1Key,
|
||||||
|
toKey: s2Key,
|
||||||
|
internalFromTo: iface.remoteIp || null,
|
||||||
|
internalToFrom: iface.localIp || null,
|
||||||
|
speedTestServerId: isJumphost1 ? s1Key : isJumphost2 ? s2Key : null,
|
||||||
|
interfaceName: iface.name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { connections: connList, servers, uiSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePingKey(a, b) {
|
||||||
|
return [String(a), String(b)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function speedKey(key1, key2) {
|
||||||
|
return [String(key1), String(key2)].sort().join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Параллельно с лимитом */
|
||||||
|
async function runWithLimit(tasks, limit = 4) {
|
||||||
|
const results = [];
|
||||||
|
let index = 0;
|
||||||
|
async function runNext() {
|
||||||
|
const i = index++;
|
||||||
|
if (i >= tasks.length) return;
|
||||||
|
try {
|
||||||
|
results[i] = { value: await tasks[i]() };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
await runNext();
|
||||||
|
}
|
||||||
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один тест на ноду в момент времени */
|
||||||
|
async function runWithOnePerKey(tasks, getKey) {
|
||||||
|
const n = tasks.length;
|
||||||
|
const keyToIndices = new Map();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const k = String(getKey(i));
|
||||||
|
if (!keyToIndices.has(k)) keyToIndices.set(k, []);
|
||||||
|
keyToIndices.get(k).push(i);
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < n; i++) results[i] = null;
|
||||||
|
const keys = Array.from(keyToIndices.keys());
|
||||||
|
let round = 0;
|
||||||
|
while (true) {
|
||||||
|
const batch = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const indices = keyToIndices.get(k);
|
||||||
|
if (round < indices.length) batch.push(indices[round]);
|
||||||
|
}
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (i) => {
|
||||||
|
try {
|
||||||
|
results[i] = { value: await tasks[i]() };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { error: err };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
round++;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить одну итерацию: пинг и speed-test по связям, записать кеш и логи.
|
||||||
|
* @param {object} opts - { baseUrl, runPing, runSpeedTest, log }
|
||||||
|
*/
|
||||||
|
async function runJob(opts) {
|
||||||
|
const { baseUrl, runPing = true, runSpeedTest = true, log = (msg) => logBuffer.push(msg) } = opts;
|
||||||
|
const pingMap = {};
|
||||||
|
const speedMap = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connections, servers } = await buildConnections();
|
||||||
|
if (connections.length === 0) {
|
||||||
|
log('Нет связей в network-config (tunnelInterfaces).');
|
||||||
|
await writeCache(pingMap, speedMap);
|
||||||
|
return { pingMap, speedMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Связей: ${connections.length}. Пинг: ${runPing ? 'да' : 'нет'}, Скорость: ${runSpeedTest ? 'да' : 'нет'}`);
|
||||||
|
|
||||||
|
if (runPing) {
|
||||||
|
const pingTasks = [];
|
||||||
|
for (const c of connections) {
|
||||||
|
const from = String(c.from);
|
||||||
|
const to = String(c.to);
|
||||||
|
if (!from || !to || from === to) continue;
|
||||||
|
const srcFrom = servers.find((s) => s.ip === from);
|
||||||
|
const srcTo = servers.find((s) => s.ip === to);
|
||||||
|
const canPingFrom = srcFrom && ['jumphost', 'home'].includes(String(srcFrom.type || '').toLowerCase()) && c.internalFromTo;
|
||||||
|
const canPingTo = srcTo && ['jumphost', 'home'].includes(String(srcTo.type || '').toLowerCase()) && c.internalToFrom;
|
||||||
|
|
||||||
|
if (canPingFrom) {
|
||||||
|
pingTasks.push(async () => {
|
||||||
|
const ekey = edgePingKey(from, to);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.fromKey,
|
||||||
|
target: c.internalFromTo,
|
||||||
|
gatewayIp: c.internalFromTo,
|
||||||
|
count: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
pingMap[ekey] = ms;
|
||||||
|
log(`Пинг ${from} → ${to}: ${ms != null ? ms + ' ms' : 'ошибка'}`);
|
||||||
|
return ms;
|
||||||
|
} catch (e) {
|
||||||
|
pingMap[ekey] = null;
|
||||||
|
log(`Пинг ${from} → ${to}: ошибка ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (canPingTo) {
|
||||||
|
pingTasks.push(async () => {
|
||||||
|
const ekey = edgePingKey(from, to);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/ping`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.toKey,
|
||||||
|
target: c.internalToFrom,
|
||||||
|
gatewayIp: c.internalToFrom,
|
||||||
|
count: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||||
|
pingMap[ekey] = ms;
|
||||||
|
log(`Пинг ${to} → ${from}: ${ms != null ? ms + ' ms' : 'ошибка'}`);
|
||||||
|
return ms;
|
||||||
|
} catch (e) {
|
||||||
|
pingMap[ekey] = null;
|
||||||
|
log(`Пинг ${to} → ${from}: ошибка ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runWithLimit(pingTasks, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runSpeedTest) {
|
||||||
|
const withSpeed = connections.filter((c) => c.speedTestServerId && c.interfaceName);
|
||||||
|
const speedTasks = withSpeed.map((c) => async () => {
|
||||||
|
const key = speedKey(c.fromKey, c.toKey);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/mikrotik/speed-test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: c.speedTestServerId,
|
||||||
|
interfaceName: c.interfaceName,
|
||||||
|
}),
|
||||||
|
signal: (() => {
|
||||||
|
const c = new AbortController();
|
||||||
|
setTimeout(() => c.abort(), 120000);
|
||||||
|
return c.signal;
|
||||||
|
})(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
|
||||||
|
speedMap[key] = {
|
||||||
|
tcpDownloadBps: data.tcpDownloadBps,
|
||||||
|
tcpUploadBps: data.tcpUploadBps,
|
||||||
|
cached: data.cached === true,
|
||||||
|
durationSeconds: data.durationSeconds,
|
||||||
|
};
|
||||||
|
const down = data.tcpDownloadBps != null ? (data.tcpDownloadBps / 1e6).toFixed(1) : '—';
|
||||||
|
const up = data.tcpUploadBps != null ? (data.tcpUploadBps / 1e6).toFixed(1) : '—';
|
||||||
|
log(`Скорость ${c.interfaceName}: ↓${down} ↑${up} Mbps`);
|
||||||
|
return speedMap[key];
|
||||||
|
}
|
||||||
|
speedMap[key] = null;
|
||||||
|
log(`Скорость ${c.interfaceName}: нет данных`);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
speedMap[key] = null;
|
||||||
|
log(`Скорость ${c.interfaceName}: ошибка ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await runWithOnePerKey(speedTasks, (i) => withSpeed[i].speedTestServerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeCache(pingMap, speedMap);
|
||||||
|
log('Кеш карты сети обновлён.');
|
||||||
|
} catch (err) {
|
||||||
|
log(`Ошибка: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pingMap, speedMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(pingMap, speedMap) {
|
||||||
|
try {
|
||||||
|
await writeS3JsonObject(NETWORK_MAP_CACHE_KEY, {
|
||||||
|
pingMap: pingMap || {},
|
||||||
|
speedMap: speedMap || {},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logBuffer.push(`Запись кеша в S3 не удалась: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let schedulerTimer = null;
|
||||||
|
|
||||||
|
function getSettingsFromUiSettings(uiSettings) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(uiSettings.networkMapSchedulerEnabled),
|
||||||
|
intervalMinutes: Math.max(1, Math.min(1440, parseInt(uiSettings.networkMapSchedulerIntervalMinutes, 10) || DEFAULT_INTERVAL_MINUTES)),
|
||||||
|
runPing: uiSettings.networkMapSchedulerRunPing !== false,
|
||||||
|
runSpeedTest: uiSettings.networkMapSchedulerRunSpeedTest !== false,
|
||||||
|
logMaxLines: Math.max(100, Math.min(2000, parseInt(uiSettings.networkMapSchedulerLogMaxLines, 10) || DEFAULT_LOG_MAX_LINES)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить планировщик (setInterval).
|
||||||
|
* @param {object} logger - pino logger
|
||||||
|
* @param {number} port - порт сервера для вызова API
|
||||||
|
*/
|
||||||
|
function initNetworkMapScheduler(logger, port) {
|
||||||
|
const log = logger || console;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
|
logBuffer.setMaxLines(settings.logMaxLines);
|
||||||
|
logBuffer.push('--- Запуск по расписанию ---');
|
||||||
|
runJob({
|
||||||
|
baseUrl,
|
||||||
|
runPing: settings.runPing,
|
||||||
|
runSpeedTest: settings.runSpeedTest,
|
||||||
|
log: (msg) => {
|
||||||
|
logBuffer.push(msg);
|
||||||
|
log.info({ component: 'network-map-scheduler' }, msg);
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
logBuffer.push(`Ошибка: ${err?.message || err}`);
|
||||||
|
log.error({ component: 'network-map-scheduler', err: err?.message }, 'Scheduler run failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedule() {
|
||||||
|
let uiSettings;
|
||||||
|
try {
|
||||||
|
const raw = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' }));
|
||||||
|
uiSettings = JSON.parse(raw?.body || '{}') || {};
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettingsFromUiSettings(uiSettings);
|
||||||
|
if (!settings.enabled) {
|
||||||
|
log.info({ component: 'network-map-scheduler' }, 'Scheduler disabled in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logBuffer.setMaxLines(settings.logMaxLines);
|
||||||
|
const intervalMs = settings.intervalMinutes * 60 * 1000;
|
||||||
|
log.info(
|
||||||
|
{ component: 'network-map-scheduler', intervalMinutes: settings.intervalMinutes },
|
||||||
|
'Network map scheduler started (first run in %d min)',
|
||||||
|
settings.intervalMinutes
|
||||||
|
);
|
||||||
|
schedulerTimer = setInterval(tick, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNetworkMapScheduler() {
|
||||||
|
if (schedulerTimer) {
|
||||||
|
clearInterval(schedulerTimer);
|
||||||
|
schedulerTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogs() {
|
||||||
|
return logBuffer.getLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogMaxLines() {
|
||||||
|
return logBuffer.maxLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogMaxLines(n) {
|
||||||
|
const num = Math.max(100, Math.min(2000, parseInt(n, 10) || 500));
|
||||||
|
logBuffer.setMaxLines(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
runJob,
|
||||||
|
initNetworkMapScheduler,
|
||||||
|
stopNetworkMapScheduler,
|
||||||
|
getLogs,
|
||||||
|
getLogMaxLines,
|
||||||
|
setLogMaxLines,
|
||||||
|
getSettingsFromUiSettings,
|
||||||
|
loadUiSettings: loadUiSettings,
|
||||||
|
buildConnections,
|
||||||
|
LogBuffer,
|
||||||
|
};
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
IconArrowsExchange,
|
IconArrowsExchange,
|
||||||
IconLayoutSidebarLeftExpand,
|
IconLayoutSidebarLeftExpand,
|
||||||
IconLayoutNavbar,
|
IconLayoutNavbar,
|
||||||
IconChartPie
|
IconChartPie,
|
||||||
|
IconClockPlay
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
import FilterManager from './FilterManager';
|
import FilterManager from './FilterManager';
|
||||||
@@ -40,6 +41,7 @@ import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
|
|||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import TrafficDashboard from './TrafficDashboard.jsx';
|
import TrafficDashboard from './TrafficDashboard.jsx';
|
||||||
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
||||||
|
import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
|
||||||
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
||||||
import PingServicesManager from './PingServicesManager.jsx';
|
import PingServicesManager from './PingServicesManager.jsx';
|
||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
@@ -230,7 +232,8 @@ function MainLayout() {
|
|||||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
||||||
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||||
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
||||||
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }
|
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
|
||||||
|
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -399,6 +402,7 @@ function MainLayout() {
|
|||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -558,9 +562,10 @@ function MainLayout() {
|
|||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,15 +84,17 @@ export default function NetworkMapDashboard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [serversRes, configRes, uiSettingsRes] = await Promise.all([
|
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
|
||||||
api.get('/servers'),
|
api.get('/servers'),
|
||||||
api.get('/network-config').catch(() => ({ data: null })),
|
api.get('/network-config').catch(() => ({ data: null })),
|
||||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||||
|
api.get('/network-map-cache').catch(() => ({ data: {} })),
|
||||||
]);
|
]);
|
||||||
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||||||
const config = configRes?.data || {};
|
const config = configRes?.data || {};
|
||||||
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
const uiSettings = uiSettingsRes?.data || {};
|
const uiSettings = uiSettingsRes?.data || {};
|
||||||
|
const cache = cacheRes?.data || {};
|
||||||
const ttlSeconds = Math.max(
|
const ttlSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
|
||||||
@@ -128,7 +130,21 @@ export default function NetworkMapDashboard() {
|
|||||||
|
|
||||||
setServers(serversList);
|
setServers(serversList);
|
||||||
setConnections(connList);
|
setConnections(connList);
|
||||||
setSpeedMap({});
|
|
||||||
|
const cacheMaxAgeMs = Math.max((ttlSeconds || 60) * 1000, 60 * 1000);
|
||||||
|
const cacheValid =
|
||||||
|
cache.updatedAt &&
|
||||||
|
Date.now() - cache.updatedAt < cacheMaxAgeMs &&
|
||||||
|
(Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0);
|
||||||
|
if (cacheValid) {
|
||||||
|
setPingMap(cache.pingMap || {});
|
||||||
|
setSpeedMap(cache.speedMap || {});
|
||||||
|
Object.keys(cache.pingMap || {}).forEach((k) => {
|
||||||
|
pingTimestampsRef.current[k] = cache.updatedAt;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSpeedMap({});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('NetworkMap fetch:', e);
|
console.error('NetworkMap fetch:', e);
|
||||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import api from './lib/api.js';
|
||||||
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
|
import {
|
||||||
|
IconClockPlay,
|
||||||
|
IconSettings,
|
||||||
|
IconTerminal2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconRefresh,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const POLL_LOGS_MS = 2500;
|
||||||
|
const INTERVAL_MIN = 1;
|
||||||
|
const INTERVAL_MAX = 1440;
|
||||||
|
const LOG_LINES_MIN = 100;
|
||||||
|
const LOG_LINES_MAX = 2000;
|
||||||
|
|
||||||
|
export default function NetworkMapSchedulerPage() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
intervalMinutes: 5,
|
||||||
|
runPing: true,
|
||||||
|
runSpeedTest: true,
|
||||||
|
logMaxLines: 500,
|
||||||
|
});
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const logEndRef = useRef(null);
|
||||||
|
const logContainerRef = useRef(null);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
const fetchSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/scheduler/network-map/settings');
|
||||||
|
setSettings({
|
||||||
|
enabled: Boolean(data.enabled),
|
||||||
|
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
|
||||||
|
runPing: data.runPing !== false,
|
||||||
|
runSpeedTest: data.runSpeedTest !== false,
|
||||||
|
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, Number(data.logMaxLines) || 500)),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/scheduler/network-map/logs');
|
||||||
|
setLogs(Array.isArray(data?.lines) ? data.lines : []);
|
||||||
|
} catch (_) {
|
||||||
|
// не показываем ошибку при опросе логов
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await fetchSettings();
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [fetchSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
const t = setInterval(fetchLogs, POLL_LOGS_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && logEndRef.current) {
|
||||||
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.patch('/scheduler/network-map/settings', {
|
||||||
|
networkMapSchedulerEnabled: settings.enabled,
|
||||||
|
networkMapSchedulerIntervalMinutes: settings.intervalMinutes,
|
||||||
|
networkMapSchedulerRunPing: settings.runPing,
|
||||||
|
networkMapSchedulerRunSpeedTest: settings.runSpeedTest,
|
||||||
|
networkMapSchedulerLogMaxLines: settings.logMaxLines,
|
||||||
|
});
|
||||||
|
await fetchSettings();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось сохранить');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunNow = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post('/scheduler/network-map/run-now');
|
||||||
|
setTimeout(fetchLogs, 500);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.message || e?.message || 'Не удалось запустить');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Планировщик карты сети"
|
||||||
|
icon={<IconClockPlay size={24} />}
|
||||||
|
meta="Пинг и замер скорости между серверами по расписанию"
|
||||||
|
/>
|
||||||
|
<div className="text-center py-5 text-muted">Загрузка настроек…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Планировщик карты сети"
|
||||||
|
icon={<IconClockPlay size={24} />}
|
||||||
|
meta="Пинг и замер скорости между серверами по расписанию; кеш ускоряет загрузку карты сети."
|
||||||
|
actions={
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleRunNow}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay className={running ? 'spin me-2' : 'me-2'} size={18} />
|
||||||
|
{running ? 'Запуск…' : 'Запустить сейчас'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||||
|
{error}
|
||||||
|
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => setError(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row row-deck row-cards g-3">
|
||||||
|
<div className="col-12 col-lg-5">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title d-flex align-items-center">
|
||||||
|
<IconSettings className="me-2" size={20} />
|
||||||
|
Настройки
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-check form-switch">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, enabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Включить планировщик</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-hint">Периодически обновлять пинг и скорость по связям из «Сетевые настройки»</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Интервал (минут)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={INTERVAL_MIN}
|
||||||
|
max={INTERVAL_MAX}
|
||||||
|
value={settings.intervalMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, parseInt(e.target.value, 10) || INTERVAL_MIN)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">От 1 до 1440 (сутки). По умолчанию 5 мин.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Задачи</label>
|
||||||
|
<div>
|
||||||
|
<label className="form-check form-switch me-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.runPing}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, runPing: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Пинг</span>
|
||||||
|
</label>
|
||||||
|
<label className="form-check form-switch">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.runSpeedTest}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, runSpeedTest: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Замер скорости</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-0">
|
||||||
|
<label className="form-label">Строк в логе</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={LOG_LINES_MIN}
|
||||||
|
max={LOG_LINES_MAX}
|
||||||
|
value={settings.logMaxLines}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
logMaxLines: Math.max(LOG_LINES_MIN, Math.min(LOG_LINES_MAX, parseInt(e.target.value, 10) || 500)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">Кольцевой буфер: от 100 до 2000 строк.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-7">
|
||||||
|
<div className="card flex-fill">
|
||||||
|
<div className="card-header d-flex align-items-center">
|
||||||
|
<h3 className="card-title d-flex align-items-center mb-0">
|
||||||
|
<IconTerminal2 className="me-2" size={20} />
|
||||||
|
Логи
|
||||||
|
</h3>
|
||||||
|
<div className="card-actions ms-auto">
|
||||||
|
<label className="form-check form-switch form-check-inline mb-0 me-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScroll}
|
||||||
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label small">Автопрокрутка</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
aria-label="Обновить логи"
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="card-body p-0"
|
||||||
|
style={{ minHeight: 320 }}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
className="mb-0 p-3 bg-dark text-light rounded-0 rounded-bottom"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
maxHeight: 420,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<span className="text-muted">Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.</span>
|
||||||
|
) : (
|
||||||
|
logs.map((line, i) => (
|
||||||
|
<span key={i} style={{ display: 'block' }}>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<span ref={logEndRef} />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconKeyboard,
|
IconKeyboard,
|
||||||
IconChartPie,
|
IconChartPie,
|
||||||
IconSettings
|
IconSettings,
|
||||||
|
IconClockPlay
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,7 @@ function CommandPalette() {
|
|||||||
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
||||||
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
|
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
|
||||||
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
|
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
|
||||||
|
{ icon: IconClockPlay, label: 'Планировщик карты сети', description: 'Пинг и скорость по расписанию, логи', action: () => navigate('/scheduler'), keywords: ['планировщик', 'scheduler', 'карта', 'сеть', 'логи'] },
|
||||||
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
||||||
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
||||||
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user