feat(alerts): refactor AlertsBell component to support dynamic positioning and improve alert display with a new panel layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s

This commit is contained in:
2026-02-24 01:40:41 +07:00
parent 574c0d25c2
commit 00d977dfb5
+176 -53
View File
@@ -1,3 +1,5 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
import { useAlerts } from '../contexts/AlertsContext.jsx';
@@ -21,11 +23,182 @@ function AlertIcon({ type, severity }) {
return <Icon size={18} className={colorClass} />;
}
const PANEL_WIDTH = 320;
const PANEL_MAX_HEIGHT = 320;
const Z_INDEX = 1050;
function AlertsPanelContent({ loading, refresh, alerts, onClose }) {
return (
<>
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
<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: `${PANEL_MAX_HEIGHT}px`, 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"
onClick={onClose}
>
<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 px-3 d-flex align-items-center gap-2 flex-nowrap">
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap" onClick={onClose}>
Настройки
</Link>
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap" onClick={onClose}>
Панель
</Link>
</div>
</>
);
}
export default function AlertsBell({ dropup = false }) {
const { alerts, total, loading, refresh } = useAlerts();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const panelRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const updatePosition = () => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const padding = 4;
const maxLeft = window.innerWidth - PANEL_WIDTH - padding;
if (dropup) {
setPosition({
left: Math.max(padding, Math.min(rect.left, maxLeft)),
bottom: window.innerHeight - rect.top + padding,
});
} else {
setPosition({
top: rect.bottom + padding,
left: Math.max(padding, Math.min(rect.left, maxLeft)),
});
}
};
useLayoutEffect(() => {
if (open) updatePosition();
}, [open, dropup]);
useEffect(() => {
if (!open) return;
const onScroll = () => updatePosition();
const onResize = () => updatePosition();
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
const onClickOutside = (e) => {
const panel = panelRef.current;
const trigger = triggerRef.current;
if (!panel || !trigger) return;
if (!panel.contains(e.target) && !trigger.contains(e.target)) setOpen(false);
};
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onClickOutside);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClickOutside);
};
}, [open]);
const handleTriggerClick = (e) => {
e.preventDefault();
if (dropup) {
setOpen((v) => !v);
}
};
if (dropup) {
return (
<>
<div className="nav-item">
<a
ref={triggerRef}
href="#"
className="nav-link px-2 d-inline-flex align-items-center justify-content-center"
onClick={handleTriggerClick}
aria-expanded={open}
aria-haspopup="true"
aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах"
>
<span className="position-relative d-inline-flex">
<IconBell size={20} />
{total > 0 && (
<span className="badge badge-sm bg-danger position-absolute rounded-pill" style={{ top: '-2px', right: '-2px' }}>
{total > 99 ? '99+' : total}
</span>
)}
</span>
</a>
</div>
{open && typeof document !== 'undefined' && createPortal(
<div
ref={panelRef}
className="dropdown-menu dropdown-menu-card show overflow-hidden shadow-lg border"
data-bs-theme="light"
style={{
position: 'fixed',
left: position.left,
...(position.bottom !== undefined ? { bottom: position.bottom } : { top: position.top }),
width: `${PANEL_WIDTH}px`,
maxWidth: 'calc(100vw - 1rem)',
zIndex: Z_INDEX,
}}
>
<AlertsPanelContent
loading={loading}
refresh={refresh}
alerts={alerts}
onClose={() => setOpen(false)}
/>
</div>,
document.body
)}
</>
);
}
return (
<div className={`nav-item dropdown${dropup ? ' dropup' : ''}`}>
<div className="nav-item dropdown">
<a
href="#"
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
@@ -43,58 +216,8 @@ export default function AlertsBell({ dropup = false }) {
)}
</span>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: '320px', maxWidth: '95vw' }}>
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
<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 px-3 d-flex align-items-center gap-2 flex-nowrap">
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap">
Настройки
</Link>
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap">
Панель
</Link>
</div>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
</div>
</div>
);