Files
ALwrity/frontend/src/components/shared/AlertsBadge.tsx

665 lines
23 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Badge, IconButton, Menu, MenuItem, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
import { Notifications as NotificationsIcon, NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
import { billingService } from '../../services/billingService';
import { useAuth } from '@clerk/clerk-react';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
import { apiClient } from '../../api/client';
interface Alert {
id: string;
type: string;
title: string;
message: string;
severity: 'error' | 'warning' | 'info';
priority: 'high' | 'medium' | 'low';
is_read: boolean;
created_at: string;
source: 'billing' | 'scheduler' | 'agents' | 'task';
metadata?: Record<string, any>;
groupKey?: string;
}
interface AlertGroup {
id: string;
title: string;
source: Alert['source'];
severity: Alert['severity'];
priority: 'high' | 'medium' | 'low';
summary: string;
count: number;
latestTimestamp: string;
alerts: Alert[];
metadata?: Record<string, any>;
actionLabel?: string;
actionHref?: string;
}
interface AlertsBadgeProps {
colorMode?: 'light' | 'dark';
}
const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
const { userId } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [alertGroups, setAlertGroups] = useState<AlertGroup[]>([]);
const [loading, setLoading] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const open = Boolean(anchorEl);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isPollingRef = useRef(false);
const schedulerDismissedRef = useRef<Set<string>>(new Set());
const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`;
const loadSchedulerDismissed = (uid: string) => {
if (!uid) return new Set<string>();
try {
const stored = localStorage.getItem(getSchedulerStorageKey(uid));
if (!stored) return new Set<string>();
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed);
}
return new Set<string>();
} catch {
return new Set<string>();
}
};
const persistSchedulerDismissed = (uid: string, dismissed: Set<string>) => {
if (!uid) return;
try {
localStorage.setItem(getSchedulerStorageKey(uid), JSON.stringify(Array.from(dismissed)));
} catch {
// ignore storage errors
}
};
const dismissSchedulerAlert = (alertId: string) => {
if (!userId) return;
const updated = new Set(schedulerDismissedRef.current);
updated.add(alertId);
schedulerDismissedRef.current = updated;
persistSchedulerDismissed(userId, updated);
};
useEffect(() => {
if (!userId) return;
schedulerDismissedRef.current = loadSchedulerDismissed(userId);
}, [userId]);
// Fetch all alerts
const rebuildGroups = (alertList: Alert[]) => {
const groups = buildAlertGroups(alertList);
setAlertGroups(groups);
const unreadGroups = groups.filter(group => group.alerts.some(alert => !alert.is_read)).length;
setUnreadCount(unreadGroups);
};
const fetchAlerts = async () => {
if (!userId || isPollingRef.current) return;
try {
isPollingRef.current = true;
setLoading(true);
const allAlerts: Alert[] = [];
// Phase 1: Fetch billing alerts
try {
const billingAlerts = await billingService.getUsageAlerts(userId, true);
const formattedBillingAlerts: Alert[] = billingAlerts.map((alert: any) => ({
id: `billing-${alert.id}`,
type: alert.type,
title: alert.title || 'Billing Alert',
message: alert.message,
severity: alert.severity || 'warning',
priority: mapSeverityToPriority(alert.severity || 'warning'),
is_read: alert.is_read || false,
created_at: alert.created_at,
source: 'billing' as const,
groupKey: `billing-${alert.type}-${alert.title || 'alert'}`
}));
allAlerts.push(...formattedBillingAlerts);
} catch (error) {
console.error('Error fetching billing alerts:', error);
}
// Phase 2: Fetch scheduler/task alerts
try {
const taskAlerts = await getTasksNeedingIntervention(userId);
const formattedSchedulerAlerts: Alert[] = taskAlerts.map((task: TaskNeedingIntervention) => {
const alertId = `scheduler-${task.task_type}-${task.task_id}`;
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
const reasonInfo = failureReasonDetails[failureReason] || failureReasonDetails.unknown;
const taskLabel = formatTaskDisplayName(task);
const message = buildSchedulerAlertMessage(task);
const timestamp = task.failure_pattern?.last_failure_time || task.last_failure || new Date().toISOString();
return {
id: alertId,
type: 'scheduler_task_failure',
title: `Task needs attention: ${taskLabel}`,
message,
severity: reasonInfo.severity,
priority: mapSchedulerReasonToPriority(failureReason),
is_read: schedulerDismissedRef.current.has(alertId),
created_at: timestamp,
source: 'scheduler' as const,
metadata: {
taskId: task.task_id,
taskType: task.task_type,
failureReason,
occurrences: task.failure_pattern?.consecutive_failures ?? 0,
lastFailure: timestamp,
},
groupKey: `scheduler-${task.task_type}-${task.task_id}`
};
});
allAlerts.push(...formattedSchedulerAlerts);
} catch (error) {
console.error('Error fetching scheduler alerts:', error);
}
// Phase 3: Fetch agents team alerts
try {
const resp = await apiClient.get('/api/agents/alerts', {
params: { unread_only: true, limit: 50 }
});
const agentAlerts = resp?.data?.data?.alerts || [];
const formattedAgentAlerts: Alert[] = agentAlerts.map((a: any) => ({
id: `agents-${a.id}`,
type: a.type || 'agent_alert',
title: a.title || 'Agents Alert',
message: a.message || '',
severity: (a.severity as any) || 'info',
priority: mapSeverityToPriority(a.severity || 'info'),
is_read: Boolean(a.read_at),
created_at: a.created_at || new Date().toISOString(),
source: 'agents' as const,
metadata: {
ctaPath: a.cta_path,
payload: a.payload,
},
groupKey: `agents-${a.type || 'agent_alert'}-${a.title || 'alert'}`
}));
allAlerts.push(...formattedAgentAlerts);
} catch (error) {
console.error('Error fetching agent alerts:', error);
}
// Sort alerts by created_at (newest first)
allAlerts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setAlerts(allAlerts);
rebuildGroups(allAlerts);
} catch (error) {
console.error('Error fetching alerts:', error);
} finally {
setLoading(false);
isPollingRef.current = false;
}
};
// Poll for alerts
useEffect(() => {
if (!userId) return;
fetchAlerts();
// Poll every 60 seconds
intervalRef.current = setInterval(() => {
fetchAlerts();
}, 60000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
// Refresh alerts when menu opens
fetchAlerts();
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMarkAsRead = async (alert: Alert) => {
try {
if (alert.source === 'billing') {
const numericId = Number(alert.id.replace('billing-', ''));
if (!Number.isNaN(numericId)) {
await billingService.markAlertRead(numericId);
}
} else if (alert.source === 'scheduler') {
dismissSchedulerAlert(alert.id);
} else if (alert.source === 'agents') {
const numericId = Number(alert.id.replace('agents-', ''));
if (!Number.isNaN(numericId)) {
await apiClient.post(`/api/agents/alerts/${numericId}/mark-read`);
}
}
// Update local state
const updated = alerts.map(a => (a.id === alert.id ? { ...a, is_read: true } : a));
setAlerts(updated);
rebuildGroups(updated);
} catch (error) {
console.error('Error marking alert as read:', error);
}
};
const handleGroupClick = async (group: AlertGroup) => {
for (const alert of group.alerts.filter(a => !a.is_read)) {
await handleMarkAsRead(alert);
}
};
const handleMarkAllAsRead = async () => {
try {
for (const alert of alerts.filter(a => !a.is_read)) {
await handleMarkAsRead(alert);
}
} catch (error) {
console.error('Error marking all alerts as read:', error);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return <ErrorIcon sx={{ color: '#f44336', fontSize: 20 }} />;
case 'warning':
return <WarningIcon sx={{ color: '#ff9800', fontSize: 20 }} />;
case 'info':
return <InfoIcon sx={{ color: '#2196f3', fontSize: 20 }} />;
default:
return <InfoIcon sx={{ color: '#757575', fontSize: 20 }} />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return '#f44336';
case 'warning':
return '#ff9800';
case 'info':
return '#2196f3';
default:
return '#757575';
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (!userId) return null;
return (
<>
<Tooltip title={unreadCount > 0 ? `${unreadCount} unread alert${unreadCount > 1 ? 's' : ''}` : 'No alerts'}>
<IconButton
onClick={handleOpen}
sx={{
color: colorMode === 'dark' ? 'white' : 'inherit',
position: 'relative',
}}
>
<Badge badgeContent={unreadCount} color="error" max={99}>
{unreadCount > 0 ? (
<NotificationsActiveIcon sx={{ color: '#ff9800' }} />
) : (
<NotificationsIcon />
)}
</Badge>
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
minWidth: 360,
maxWidth: 450,
maxHeight: '80vh',
overflow: 'auto',
}
}}
>
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Alerts
</Typography>
{unreadCount > 0 && (
<Button
size="small"
onClick={handleMarkAllAsRead}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
Mark all as read
</Button>
)}
</Box>
</Box>
{loading && alertGroups.length === 0 ? (
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Loading alerts...
</Typography>
</Box>
) : alertGroups.length === 0 ? (
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
<CheckCircleIcon sx={{ fontSize: 48, color: '#4caf50', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
No alerts
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{alertGroups.map((group, index) => (
<React.Fragment key={group.id}>
<ListItem
sx={{
bgcolor: group.alerts.every(a => a.is_read) ? 'transparent' : 'rgba(255, 152, 0, 0.05)',
'&:hover': {
bgcolor: 'rgba(0,0,0,0.04)',
},
cursor: 'pointer',
}}
onClick={() => handleGroupClick(group)}
>
<ListItemIcon>
{getSeverityIcon(group.severity)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: group.alerts.every(a => a.is_read) ? 400 : 700 }}>
{group.title}
</Typography>
<Chip
label={group.source === 'agents' ? 'Agents Team' : group.source === 'scheduler' ? 'Scheduler' : group.source === 'billing' ? 'Billing' : group.source}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: `${getSeverityColor(group.severity)}20`,
color: getSeverityColor(group.severity),
fontWeight: 600,
}}
/>
<Chip
label={`${group.count} occurrence${group.count > 1 ? 's' : ''}`}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: 'rgba(0,0,0,0.08)',
color: colorMode === 'dark' ? 'white' : 'inherit',
}}
/>
<Chip
label={`${group.priority.toUpperCase()} priority`}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: priorityStyles[group.priority].bg,
color: priorityStyles[group.priority].color,
fontWeight: 600,
}}
/>
</Box>
}
secondary={
<>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{group.summary}
</Typography>
{group.alerts.slice(0, 2).map((alert, idx) => (
<Typography key={alert.id} variant="caption" color="text.secondary" sx={{ display: 'block', mt: idx === 0 ? 0.5 : 0 }}>
{alert.message}
</Typography>
))}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Last alert: {formatDate(group.latestTimestamp)}
</Typography>
{group.actionHref && (
<Button
size="small"
sx={{ mt: 1, textTransform: 'none', fontSize: '0.75rem' }}
onClick={(event) => {
event.stopPropagation();
if (group.actionHref?.startsWith('http')) {
window.open(group.actionHref, '_blank');
} else {
window.location.href = group.actionHref!;
}
}}
>
{group.actionLabel || 'View Details'}
</Button>
)}
</>
}
/>
</ListItem>
{index < alertGroups.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
{alertGroups.length > 0 && (
<>
<Divider />
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
<Button
size="small"
onClick={() => {
handleClose();
window.location.href = '/billing';
}}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
View All Alerts
</Button>
</Box>
</>
)}
</Menu>
</>
);
};
const failureReasonDetails: Record<string, { label: string; severity: 'error' | 'warning' | 'info'; guidance: string }> = {
api_limit: {
label: 'API limit exceeded',
severity: 'error',
guidance: 'Usage quota exceeded. Consider upgrading or waiting for quota reset.',
},
auth_error: {
label: 'Authentication error',
severity: 'error',
guidance: 'Refresh your platform credentials and retry the task.',
},
network_error: {
label: 'Network error',
severity: 'warning',
guidance: 'Network instability detected. Retry once connectivity is restored.',
},
config_error: {
label: 'Configuration issue',
severity: 'warning',
guidance: 'Review task configuration and ensure required inputs are set.',
},
unknown: {
label: 'Unknown failure',
severity: 'info',
guidance: 'Check task logs for more details.',
},
};
const formatTaskDisplayName = (task: TaskNeedingIntervention): string => {
if (task.task_type === 'oauth_token_monitoring') {
return `OAuth ${task.platform?.toUpperCase() || 'Token'}`;
}
if (task.task_type === 'website_analysis') {
if (task.website_url) {
return `Website Analysis (${task.website_url})`;
}
return 'Website Analysis';
}
if (task.task_type.includes('_insights')) {
return `${task.platform?.toUpperCase() || 'Platform'} Insights`;
}
return task.task_type.replace(/_/g, ' ');
};
const buildSchedulerAlertMessage = (task: TaskNeedingIntervention): string => {
const reasonKey = task.failure_pattern?.failure_reason || 'unknown';
const reasonInfo = failureReasonDetails[reasonKey] || failureReasonDetails.unknown;
const consecutive = task.failure_pattern?.consecutive_failures ?? 0;
const recent = task.failure_pattern?.recent_failures ?? 0;
return `${reasonInfo.label}. ${consecutive} consecutive failures, ${recent} in the last 7 days. ${reasonInfo.guidance}`;
};
const getAlertAction = (alert: Alert): { label?: string; href?: string } => {
if (alert.source === 'billing') {
return {
label: 'Open Billing',
href: '/billing',
};
}
if (alert.source === 'scheduler') {
const taskId = alert.metadata?.taskId;
if (taskId) {
return {
label: `Review Task #${taskId}`,
href: `/scheduler?taskId=${taskId}`,
};
}
return {
label: 'View Scheduler',
href: '/scheduler#tasks',
};
}
if (alert.source === 'agents') {
const ctaPath = alert.metadata?.ctaPath;
if (typeof ctaPath === 'string' && ctaPath.trim()) {
return { label: 'Open', href: ctaPath };
}
return { label: 'View Agents', href: '/content-planning' };
}
if (alert.source === 'task') {
return {
label: 'View Tasks',
href: '/tasks',
};
}
return {};
};
const mapSeverityToPriority = (severity: string): 'high' | 'medium' | 'low' => {
if (severity === 'error') return 'high';
if (severity === 'warning') return 'medium';
return 'low';
};
const mapSchedulerReasonToPriority = (reason: string): 'high' | 'medium' | 'low' => {
switch (reason) {
case 'api_limit':
case 'auth_error':
return 'high';
case 'network_error':
return 'medium';
default:
return 'low';
}
};
const priorityRank: Record<'high' | 'medium' | 'low', number> = {
high: 0,
medium: 1,
low: 2,
};
const priorityStyles: Record<'high' | 'medium' | 'low', { bg: string; color: string }> = {
high: { bg: 'rgba(244,67,54,0.15)', color: '#f44336' },
medium: { bg: 'rgba(255,152,0,0.2)', color: '#ff9800' },
low: { bg: 'rgba(33,150,243,0.15)', color: '#2196f3' },
};
const buildAlertGroups = (alertList: Alert[]): AlertGroup[] => {
const map = new Map<string, AlertGroup>();
for (const alert of alertList) {
const key = alert.groupKey || `${alert.source}-${alert.type}-${alert.title}`;
const existing = map.get(key);
const timestamp = alert.created_at;
if (existing) {
existing.count += 1;
existing.alerts.push(alert);
if (new Date(timestamp).getTime() > new Date(existing.latestTimestamp).getTime()) {
existing.latestTimestamp = timestamp;
existing.summary = alert.message;
}
if (priorityRank[alert.priority] < priorityRank[existing.priority]) {
existing.priority = alert.priority;
existing.severity = alert.severity;
}
} else {
const action = getAlertAction(alert);
map.set(key, {
id: key,
title: alert.title,
source: alert.source,
severity: alert.severity,
priority: alert.priority,
summary: alert.message,
count: 1,
latestTimestamp: timestamp,
alerts: [alert],
metadata: alert.metadata,
actionLabel: action.label,
actionHref: action.href,
});
}
}
return Array.from(map.values()).sort((a, b) => {
const priorityCompare = priorityRank[a.priority] - priorityRank[b.priority];
if (priorityCompare !== 0) return priorityCompare;
return new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime();
});
};
export default AlertsBadge;