diff --git a/backend/routes/alertsRoutes.js b/backend/routes/alertsRoutes.js new file mode 100644 index 0000000..87c3dac --- /dev/null +++ b/backend/routes/alertsRoutes.js @@ -0,0 +1,144 @@ +/** + * Система оповещений: агрегирует проблемы из availability и resources/stats. + * Критерии проблем: сервер недоступен, MikroTik недоступен, высокое CPU/RAM/HDD. + */ + +const { sendError } = require('../middleware/errorHandler'); +const { readServersFromS3 } = require('./serversRoutes'); +const { checkOneServerFast } = require('./miscRoutes'); +const { getResourceStatsData } = require('./resourceStatsRoutes'); + +/** Пороги для ресурсов (проценты). */ +const THRESHOLD_CPU = 85; +const THRESHOLD_RAM = 85; +const THRESHOLD_HDD = 90; + +/** + * GET /api/alerts + * Возвращает список активных оповещений. + */ +async function getAlerts(req, res) { + try { + const servers = await readServersFromS3(); + const alerts = []; + const now = new Date().toISOString(); + + // 1) Доступность серверов (TCP 80/443) + const availabilityChecks = await Promise.allSettled( + servers.map((s) => checkOneServerFast(s)) + ); + servers.forEach((server, i) => { + const online = availabilityChecks[i].status === 'fulfilled' && availabilityChecks[i].value; + if (!online) { + const name = server.name || server.dns || server.ip || server.id || `Сервер #${i + 1}`; + alerts.push({ + id: `server-offline-${server.id || server.dns || server.ip || i}`, + type: 'server_offline', + severity: 'critical', + title: 'Сервер недоступен', + description: `${name} не отвечает на TCP (80/443).`, + entity: name, + entityId: server.id || server.dns || server.ip, + link: '/servers', + at: now, + }); + } + }); + + // 2) Ресурсы роутеров (MikroTik): ошибка доступа, высокое CPU/RAM/HDD + let resourceData; + try { + resourceData = await getResourceStatsData(); + } catch (err) { + console.warn('alerts: getResourceStatsData failed', err?.message); + resourceData = { routers: [] }; + } + + const routers = resourceData.routers || []; + routers.forEach((router) => { + const { serverId, name, error, resource } = router; + const entityName = name || serverId; + + if (error) { + alerts.push({ + id: `mikrotik-error-${serverId}`, + type: 'mikrotik_unreachable', + severity: 'critical', + title: 'MikroTik недоступен', + description: `${entityName}: ${error}`, + entity: entityName, + entityId: serverId, + link: '/resource-stats', + at: now, + }); + return; + } + + if (!resource) return; + + if (resource.cpuLoad != null && resource.cpuLoad >= THRESHOLD_CPU) { + alerts.push({ + id: `cpu-high-${serverId}`, + type: 'high_cpu', + severity: 'warning', + title: 'Высокое использование CPU', + description: `${entityName}: ${resource.cpuLoad}% (порог ${THRESHOLD_CPU}%).`, + entity: entityName, + entityId: serverId, + value: resource.cpuLoad, + threshold: THRESHOLD_CPU, + link: '/resource-stats', + at: now, + }); + } + if (resource.memoryUsagePercent != null && resource.memoryUsagePercent >= THRESHOLD_RAM) { + alerts.push({ + id: `ram-high-${serverId}`, + type: 'high_ram', + severity: 'warning', + title: 'Высокое использование RAM', + description: `${entityName}: ${resource.memoryUsagePercent}% (порог ${THRESHOLD_RAM}%).`, + entity: entityName, + entityId: serverId, + value: resource.memoryUsagePercent, + threshold: THRESHOLD_RAM, + link: '/resource-stats', + at: now, + }); + } + if (resource.hddUsagePercent != null && resource.hddUsagePercent >= THRESHOLD_HDD) { + alerts.push({ + id: `hdd-high-${serverId}`, + type: 'high_hdd', + severity: 'warning', + title: 'Высокое использование диска', + description: `${entityName}: ${resource.hddUsagePercent}% (порог ${THRESHOLD_HDD}%).`, + entity: entityName, + entityId: serverId, + value: resource.hddUsagePercent, + threshold: THRESHOLD_HDD, + link: '/resource-stats', + at: now, + }); + } + }); + + return res.json({ + alerts, + total: alerts.length, + at: now, + }); + } catch (error) { + console.error('getAlerts:', error); + return sendError( + res, + 500, + error.message || 'Ошибка загрузки оповещений', + 'E_ALERTS' + ); + } +} + +module.exports = { + getAlerts, +}; diff --git a/backend/routes/miscRoutes.js b/backend/routes/miscRoutes.js index 9c46110..781d4ee 100644 --- a/backend/routes/miscRoutes.js +++ b/backend/routes/miscRoutes.js @@ -776,5 +776,6 @@ module.exports = { getPingServicesList, getPingServices, refreshPingServicesCache, + checkOneServerFast, }; diff --git a/backend/routes/resourceStatsRoutes.js b/backend/routes/resourceStatsRoutes.js index 5a7d4ed..7394e8c 100644 --- a/backend/routes/resourceStatsRoutes.js +++ b/backend/routes/resourceStatsRoutes.js @@ -74,13 +74,11 @@ function parseResource(raw) { } /** - * GET /api/resources/stats - * По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам. + * Внутренняя функция: возвращает данные по ресурсам роутеров (без HTTP). */ -async function getResourceStats(req, res) { - try { - const servers = await readServersFromS3(); - const routers = (Array.isArray(servers) ? servers : []).filter( +async function getResourceStatsData() { + const servers = await readServersFromS3(); + const routers = (Array.isArray(servers) ? servers : []).filter( (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || @@ -132,7 +130,17 @@ async function getResourceStats(req, res) { }) ); - return res.json({ routers: results }); + return { routers: results }; +} + +/** + * GET /api/resources/stats + * По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам. + */ +async function getResourceStats(req, res) { + try { + const data = await getResourceStatsData(); + return res.json(data); } catch (error) { console.error('getResourceStats:', error); return sendError( @@ -146,4 +154,5 @@ async function getResourceStats(req, res) { module.exports = { getResourceStats, + getResourceStatsData, }; diff --git a/backend/server.js b/backend/server.js index 627f8fe..3431fa3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -31,6 +31,7 @@ const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes'); const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); const resourceStatsRoutes = require('./routes/resourceStatsRoutes'); +const alertsRoutes = require('./routes/alertsRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); @@ -483,6 +484,9 @@ app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats); // === RESOURCE STATS (RAM/CPU/HDD по роутерам) === app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats); +// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) === +app.get('/api/alerts', alertsRoutes.getAlerts); + // === MIKROTIK BACKUPS (S3) === app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3ac8aa3..f7d64d0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -58,6 +58,8 @@ import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPal import ErrorBoundary from './components/ErrorBoundary.jsx'; import NetworkErrorHandler from './components/NetworkErrorHandler.jsx'; import { PingProvider } from './contexts/PingContext.jsx'; +import { AlertsProvider } from './contexts/AlertsContext.jsx'; +import AlertsBell from './components/AlertsBell.jsx'; // --- Simple i18n (RU/EN) --- const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k }); @@ -124,12 +126,14 @@ function App() { - - - - - - + + + + + + + + @@ -422,6 +426,9 @@ function MainLayout() { { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')} +
+ +
@@ -580,6 +587,9 @@ function MainLayout() { { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')} +
+ +
diff --git a/frontend/src/Dashboard.jsx b/frontend/src/Dashboard.jsx index dbb59bc..6860509 100644 --- a/frontend/src/Dashboard.jsx +++ b/frontend/src/Dashboard.jsx @@ -18,6 +18,11 @@ import { IconSearch, IconChevronDown, IconChevronUp, + IconBell, + IconCpu, + IconDeviceDesktop, + IconDatabase, + IconServerOff, } from '@tabler/icons-react'; import { getIconById } from './lib/brandIcons.js'; import PageHeader from './components/PageHeader.jsx'; @@ -26,6 +31,7 @@ import TrendIndicator from './components/TrendIndicator.jsx'; import LastSaved from './components/LastSaved.jsx'; import Tooltip from './components/Tooltip.jsx'; import Sparkline from './components/Sparkline.jsx'; +import { useAlerts } from './contexts/AlertsContext.jsx'; function StatCard({ icon: Icon, color, value, title, subtitle, to, trend, previousValue }) { return ( @@ -177,7 +183,16 @@ function PingServiceCard({ config, ms, previousMs, history = [], loading, isExpa ); } +const ALERT_ICONS = { + server_offline: IconServerOff, + mikrotik_unreachable: IconServerOff, + high_cpu: IconCpu, + high_ram: IconDeviceDesktop, + high_hdd: IconDatabase, +}; + function Dashboard() { + const { alerts, loading: alertsLoading, refresh: refreshAlerts } = useAlerts(); // Глобальный поиск удалён по требованию UX const [stats, setStats] = useState({ domainsCount: null, @@ -407,6 +422,59 @@ function Dashboard() { ))} + {/* Активные оповещения */} +
+
+

+ + Активные оповещения + {alerts.length > 0 && ( + {alerts.length} + )} +

+ +
+ {alerts.length === 0 && !alertsLoading && ( +
+
+ Нет активных оповещений +
+
+ )} + {alerts.length > 0 && ( +
+
+ {alerts.map((alert) => { + const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle; + return ( + + + + +
+
{alert.title}
+
{alert.description}
+
+ + ); + })} +
+
+ )} +
+ {/* Основные метрики */}
diff --git a/frontend/src/components/AlertsBell.jsx b/frontend/src/components/AlertsBell.jsx new file mode 100644 index 0000000..6d27405 --- /dev/null +++ b/frontend/src/components/AlertsBell.jsx @@ -0,0 +1,96 @@ +import { Link } from 'react-router-dom'; +import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react'; +import { useAlerts } from '../contexts/AlertsContext.jsx'; + +const SEVERITY_ICONS = { + critical: IconAlertTriangle, + warning: IconAlertTriangle, +}; + +const TYPE_ICONS = { + server_offline: IconServerOff, + mikrotik_unreachable: IconServerOff, + high_cpu: IconCpu, + high_ram: IconDeviceDesktop, + high_hdd: IconDatabase, +}; + +function AlertIcon({ type, severity }) { + const Icon = TYPE_ICONS[type] || SEVERITY_ICONS[severity] || IconAlertTriangle; + const colorClass = severity === 'critical' ? 'text-danger' : 'text-warning'; + return ; +} + +export default function AlertsBell() { + const { alerts, total, loading, refresh } = useAlerts(); + + return ( +
+ e.preventDefault()} + aria-label={`Оповещения: ${total} активных`} + title="Оповещения о проблемах" + > + + {total > 0 && ( + + {total > 99 ? '99+' : total} + + )} + +
+
+ Оповещения + +
+
+ {alerts.length === 0 && !loading && ( +
+ Нет активных оповещений +
+ )} + {alerts.slice(0, 20).map((alert) => ( + +
+ + + +
+
{alert.title}
+
+ {alert.entity} +
+
+
+ + ))} + {alerts.length > 20 && ( +
+ и ещё {alerts.length - 20}… +
+ )} +
+
+ + Перейти к панели + +
+
+
+ ); +} diff --git a/frontend/src/contexts/AlertsContext.jsx b/frontend/src/contexts/AlertsContext.jsx new file mode 100644 index 0000000..7519d40 --- /dev/null +++ b/frontend/src/contexts/AlertsContext.jsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; +import api from '../lib/api.js'; + +const AlertsContext = createContext(null); + +const REFRESH_INTERVAL_MS = 60 * 1000; // 1 минута + +export function AlertsProvider({ children }) { + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const intervalRef = useRef(null); + + const fetchAlerts = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data } = await api.get('/alerts'); + setAlerts(Array.isArray(data?.alerts) ? data.alerts : []); + } catch (e) { + if (e?.name !== 'CanceledError' && e?.code !== 'ERR_CANCELED') { + setError(e?.message || 'Не удалось загрузить оповещения'); + setAlerts([]); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAlerts(); + intervalRef.current = setInterval(fetchAlerts, REFRESH_INTERVAL_MS); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchAlerts]); + + const value = { + alerts, + total: alerts.length, + loading, + error, + refresh: fetchAlerts, + }; + + return ( + + {children} + + ); +} + +export function useAlerts() { + const ctx = useContext(AlertsContext); + if (!ctx) throw new Error('useAlerts must be used within AlertsProvider'); + return ctx; +}