AI Story Writer Backend Migration Complete, Frontend UI Components Added
This commit is contained in:
624
frontend/src/components/shared/AlertsBadge.tsx
Normal file
624
frontend/src/components/shared/AlertsBadge.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
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';
|
||||
|
||||
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' | '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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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}
|
||||
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 === '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;
|
||||
@@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Chip, Button, Tooltip } from '@mui/material';
|
||||
import { PlayArrow } from '@mui/icons-material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import UserBadge from './UserBadge';
|
||||
import UsageDashboard from './UsageDashboard';
|
||||
import HeaderControls from './HeaderControls';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
@@ -407,10 +406,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
)}
|
||||
{rightContent}
|
||||
|
||||
{/* Usage Dashboard - Show API usage statistics */}
|
||||
<UsageDashboard compact={true} />
|
||||
|
||||
<UserBadge colorMode="dark" />
|
||||
<HeaderControls colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
|
||||
32
frontend/src/components/shared/HeaderControls.tsx
Normal file
32
frontend/src/components/shared/HeaderControls.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import AlertsBadge from './AlertsBadge';
|
||||
import UserBadge from './UserBadge';
|
||||
|
||||
interface HeaderControlsProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
showAlerts?: boolean;
|
||||
showUser?: boolean;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
const HeaderControls: React.FC<HeaderControlsProps> = ({
|
||||
colorMode = 'light',
|
||||
showAlerts = true,
|
||||
showUser = true,
|
||||
gap = 1.5,
|
||||
}) => {
|
||||
if (!showAlerts && !showUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap }}>
|
||||
{showAlerts && <AlertsBadge colorMode={colorMode} />}
|
||||
{showUser && <UserBadge colorMode={colorMode} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderControls;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip } from '@mui/material';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider } from '@mui/material';
|
||||
import { useUser, useClerk } from '@clerk/clerk-react';
|
||||
import { useSubscription } from '../../contexts/SubscriptionContext';
|
||||
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
|
||||
import UsageDashboard from './UsageDashboard';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface UserBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
@@ -12,6 +15,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { signOut } = useClerk();
|
||||
const { subscription } = useSubscription();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
@@ -20,8 +24,43 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
|
||||
}, [user]);
|
||||
|
||||
// Fetch system status for status bulb
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/content-planning/monitoring/lightweight-stats');
|
||||
const result = response.data;
|
||||
if (result.status === 'success' && result.data) {
|
||||
setSystemStatus(result.data.status || 'unknown');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching system status:', err);
|
||||
setSystemStatus('unknown');
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchSystemStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!isSignedIn) return null;
|
||||
|
||||
// Get status bulb color
|
||||
const getStatusBulbColor = () => {
|
||||
switch (systemStatus) {
|
||||
case 'healthy':
|
||||
return '#4caf50'; // Green
|
||||
case 'warning':
|
||||
return '#ff9800'; // Orange
|
||||
case 'critical':
|
||||
return '#f44336'; // Red
|
||||
default:
|
||||
return '#757575'; // Gray for unknown
|
||||
}
|
||||
};
|
||||
|
||||
// Get plan display info
|
||||
const getPlanColor = () => {
|
||||
switch (subscription?.plan) {
|
||||
@@ -65,24 +104,65 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'}`}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'} - System: ${systemStatus.toUpperCase()}`}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
{/* Status Bulb */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: getStatusBulbColor(),
|
||||
border: `2px solid ${colorMode === 'dark' ? '#1a1a1a' : 'white'}`,
|
||||
boxShadow: `0 0 8px ${getStatusBulbColor()}80`,
|
||||
animation: systemStatus === 'healthy' ? 'pulse 2s ease-in-out infinite' : 'none',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
'50%': {
|
||||
opacity: 0.8,
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{user?.fullName || user?.username || 'User'}
|
||||
@@ -110,6 +190,50 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* System Status Indicator */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
System Health
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
|
||||
<SystemStatusIndicator />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Usage Dashboard */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
Usage Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<UsageDashboard compact={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }}>
|
||||
Manage Subscription
|
||||
</MenuItem>
|
||||
|
||||
Reference in New Issue
Block a user