2b5223097f
Notification filtering: - lifecycle_hooks.py: only instance.error and instance.health_changed with status=running generate notifications. All other lifecycle events (created, started, stopped, restarted, deleted) are filtered out. - health_monitor.py: only error and unhealthy states generate notifications. Running/recovered state no longer creates info notifications. - _derive_title now maps instance.health_changed to "Container ready". Clear-all button: - Added dismiss_all() to NotificationService - Added DELETE /notifications endpoint for bulk dismiss - Frontend: clearAllNotifications API, clearAll in notification context, "Clear all" button in notification drawer alongside "Mark all as read" - Added CSS for .notification-clear-all with danger hover state - Updated notification-center tests Quality gates: pytest (21 passed), vitest (11 passed)
135 lines
2.9 KiB
TypeScript
135 lines
2.9 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useNotifications } from "../hooks/use-notifications";
|
|
import { NotificationItem } from "./notification-item";
|
|
import { Icon } from "./icon";
|
|
|
|
interface NotificationCenterProps {
|
|
isMobileTerminal?: boolean;
|
|
}
|
|
|
|
export function NotificationCenter({
|
|
isMobileTerminal = false,
|
|
}: NotificationCenterProps) {
|
|
const {
|
|
notifications,
|
|
unreadCount,
|
|
markRead,
|
|
markAllRead,
|
|
clearAll,
|
|
dismiss,
|
|
refreshList,
|
|
isDropdownOpen,
|
|
setIsDropdownOpen,
|
|
} = useNotifications();
|
|
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isDropdownOpen) return;
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleMouseDown);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleMouseDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [isDropdownOpen, setIsDropdownOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isDropdownOpen) {
|
|
void refreshList();
|
|
}
|
|
}, [isDropdownOpen, refreshList]);
|
|
|
|
if (isMobileTerminal) {
|
|
return null;
|
|
}
|
|
|
|
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
|
|
|
|
return (
|
|
<div className="notification-center">
|
|
<button
|
|
type="button"
|
|
className="notification-bell"
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
aria-label="Notifications"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={isDropdownOpen}
|
|
>
|
|
<Icon name="bell" size="md" />
|
|
{unreadCount > 0 && (
|
|
<span className="nav-badge notification-badge">{badgeText}</span>
|
|
)}
|
|
</button>
|
|
|
|
{isDropdownOpen && (
|
|
<div
|
|
ref={dropdownRef}
|
|
role="dialog"
|
|
aria-label="Notifications"
|
|
className="notification-dropdown"
|
|
>
|
|
<div className="notification-dropdown-header">
|
|
<span>Notifications</span>
|
|
</div>
|
|
|
|
<ul className="notification-list">
|
|
{notifications.length === 0 ? (
|
|
<li className="notification-empty">No notifications</li>
|
|
) : (
|
|
notifications.map((n) => (
|
|
<NotificationItem
|
|
key={n.id}
|
|
notification={n}
|
|
onMarkRead={markRead}
|
|
onDismiss={dismiss}
|
|
/>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
{notifications.length > 0 && (
|
|
<div className="notification-dropdown-footer">
|
|
<button
|
|
type="button"
|
|
className="notification-mark-all"
|
|
onClick={() => {
|
|
void markAllRead();
|
|
}}
|
|
>
|
|
Mark all as read
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="notification-clear-all"
|
|
onClick={() => {
|
|
void clearAll();
|
|
}}
|
|
>
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|