feat(api): add alerts API endpoint and refactor resource stats retrieval for improved data handling

This commit is contained in:
2026-02-24 00:25:36 +07:00
parent f0db9ed013
commit 3f41bd4c7c
8 changed files with 402 additions and 13 deletions
+144
View File
@@ -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,
};
+1
View File
@@ -776,5 +776,6 @@ module.exports = {
getPingServicesList,
getPingServices,
refreshPingServicesCache,
checkOneServerFast,
};
+16 -7
View File
@@ -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,
};
+4
View File
@@ -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);
+16 -6
View File
@@ -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() {
<LanguageProvider>
<ThemeProvider>
<PingProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
<AlertsProvider>
<ToastContainer>
<NotifyProvider>
<NetworkErrorHandler />
<MainLayout />
</NotifyProvider>
</ToastContainer>
</AlertsProvider>
</PingProvider>
</ThemeProvider>
</LanguageProvider>
@@ -422,6 +426,9 @@ function MainLayout() {
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item me-1">
<AlertsBell />
</div>
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
@@ -580,6 +587,9 @@ function MainLayout() {
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item">
<AlertsBell />
</div>
<div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div>
+68
View File
@@ -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() {
))}
</div>
{/* Активные оповещения */}
<div className="mb-4">
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0 d-flex align-items-center gap-2">
<IconBell size={24} />
Активные оповещения
{alerts.length > 0 && (
<span className="badge bg-danger rounded-pill">{alerts.length}</span>
)}
</h3>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => refreshAlerts()}
disabled={alertsLoading}
>
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить
</button>
</div>
{alerts.length === 0 && !alertsLoading && (
<div className="card">
<div className="card-body text-center text-muted py-4">
Нет активных оповещений
</div>
</div>
)}
{alerts.length > 0 && (
<div className="card">
<div className="list-group list-group-flush">
{alerts.map((alert) => {
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
return (
<Link
key={alert.id}
to={alert.link || '/resource-stats'}
className="list-group-item list-group-item-action d-flex align-items-center gap-3"
>
<span className={`avatar avatar-sm flex-shrink-0 bg-${alert.severity === 'critical' ? 'danger' : 'warning'}-lt text-${alert.severity === 'critical' ? 'danger' : 'warning'}`}>
<Icon size={20} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-semibold">{alert.title}</div>
<div className="text-muted small">{alert.description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
{/* Основные метрики */}
<div className="row g-2 g-md-3 mb-4">
<div className="col-6 col-lg-3">
+96
View File
@@ -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 <Icon size={18} className={colorClass} />;
}
export default function AlertsBell() {
const { alerts, total, loading, refresh } = useAlerts();
return (
<div className="nav-item dropdown">
<a
href="#"
className="nav-link px-2 dropdown-toggle position-relative"
data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute top-0 end-0 translate-middle rounded-pill">
{total > 99 ? '99+' : total}
</span>
)}
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card" style={{ width: '320px', maxWidth: '95vw' }}>
<div className="card-header d-flex align-items-center justify-content-between py-2">
<span className="fw-semibold">Оповещения</span>
<button
type="button"
className="btn btn-ghost-secondary btn-sm"
onClick={(e) => { e.preventDefault(); refresh(); }}
disabled={loading}
title="Обновить"
>
{loading ? '…' : '↻'}
</button>
</div>
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: '320px', overflowY: 'auto' }}>
{alerts.length === 0 && !loading && (
<div className="list-group-item text-muted small text-center py-3">
Нет активных оповещений
</div>
)}
{alerts.slice(0, 20).map((alert) => (
<Link
key={alert.id}
to={alert.link || '/dashboard'}
className="list-group-item list-group-item-action py-2"
>
<div className="d-flex align-items-start gap-2">
<span className="flex-shrink-0 mt-1">
<AlertIcon type={alert.type} severity={alert.severity} />
</span>
<div className="min-w-0 flex-grow-1">
<div className="fw-medium small">{alert.title}</div>
<div className="text-muted small text-truncate" title={alert.description}>
{alert.entity}
</div>
</div>
</div>
</Link>
))}
{alerts.length > 20 && (
<div className="list-group-item text-muted small text-center py-2">
и ещё {alerts.length - 20}
</div>
)}
</div>
<div className="card-footer py-2">
<Link to="/dashboard" className="btn btn-sm btn-outline-primary w-100">
Перейти к панели
</Link>
</div>
</div>
</div>
);
}
+57
View File
@@ -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 (
<AlertsContext.Provider value={value}>
{children}
</AlertsContext.Provider>
);
}
export function useAlerts() {
const ctx = useContext(AlertsContext);
if (!ctx) throw new Error('useAlerts must be used within AlertsProvider');
return ctx;
}