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

This commit is contained in:
2026-02-18 12:41:20 +07:00
parent fbe9a07727
commit dbca38d31a
8 changed files with 902 additions and 13 deletions
+8 -3
View File
@@ -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() {
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
@@ -558,9 +562,10 @@ function MainLayout() {
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Routes>
</main>
</div>
</div>
+18 -2
View File
@@ -84,15 +84,17 @@ export default function NetworkMapDashboard() {
setLoading(true);
setError(null);
try {
const [serversRes, configRes, uiSettingsRes] = await Promise.all([
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
api.get('/servers'),
api.get('/network-config').catch(() => ({ data: null })),
api.get('/ui-settings').catch(() => ({ data: {} })),
api.get('/network-map-cache').catch(() => ({ data: {} })),
]);
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
const config = configRes?.data || {};
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
const uiSettings = uiSettingsRes?.data || {};
const cache = cacheRes?.data || {};
const ttlSeconds = Math.max(
0,
parseInt(uiSettings.networkMapPingCacheSeconds, 10) || 0
@@ -128,7 +130,21 @@ export default function NetworkMapDashboard() {
setServers(serversList);
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) {
console.error('NetworkMap fetch:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
+310
View File
@@ -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>
);
}
+3 -1
View File
@@ -12,7 +12,8 @@ import {
IconDownload,
IconKeyboard,
IconChartPie,
IconSettings
IconSettings,
IconClockPlay
} from '@tabler/icons-react'
/**
@@ -42,6 +43,7 @@ function CommandPalette() {
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
{ 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: 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'] },