diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js
index 74c9470..e1df7dc 100644
--- a/backend/routes/mikrotikConfigRoutes.js
+++ b/backend/routes/mikrotikConfigRoutes.js
@@ -1155,4 +1155,5 @@ module.exports = {
pingViaInterface,
runPingViaRouter,
speedTestViaTunnel,
+ loadNetworkConfig,
};
diff --git a/backend/routes/schedulerRoutes.js b/backend/routes/schedulerRoutes.js
new file mode 100644
index 0000000..9eace34
--- /dev/null
+++ b/backend/routes/schedulerRoutes.js
@@ -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
\ No newline at end of file
diff --git a/backend/server.js b/backend/server.js
index 179d652..4bf39a1 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -31,6 +31,8 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
+const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
+const schedulerRoutes = require('./routes/schedulerRoutes');
const app = express();
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/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 ===
app.post('/api/mikrotik/validate', async (req, res) => {
const { config } = req.body;
@@ -503,12 +514,15 @@ app.use(errorHandler);
app.listen(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');
-}
-
diff --git a/backend/services/networkMapScheduler.js b/backend/services/networkMapScheduler.js
new file mode 100644
index 0000000..7ace435
--- /dev/null
+++ b/backend/services/networkMapScheduler.js
@@ -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,
+};
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index d410d84..25b2506 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -23,7 +23,8 @@ import {
IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar,
- IconChartPie
+ IconChartPie,
+ IconClockPlay
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -40,6 +41,7 @@ import InterfaceSpeedTest from './InterfaceSpeedTest.jsx';
import Dashboard from './Dashboard';
import TrafficDashboard from './TrafficDashboard.jsx';
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
+import NetworkMapSchedulerPage from './NetworkMapSchedulerPage.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.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-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', 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() {
+ {logs.length === 0 ? (
+ Логов пока нет. Включите планировщик и нажмите «Запустить сейчас» или дождитесь следующего запуска.
+ ) : (
+ logs.map((line, i) => (
+
+ {line}
+
+ ))
+ )}
+
+
+