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
+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;
}