Bing Analytics and Insights added, background jobs added, database setup updated, environment setup updated, frontend updated, backend updated.
Onboarding Manager and Router Manager refactored, analytics and background jobs added, database setup updated, environment setup updated, frontend updated, backend updated. Critical onboarding database migration implemented.
This commit is contained in:
445
frontend/src/components/shared/BackgroundJobManager.tsx
Normal file
445
frontend/src/components/shared/BackgroundJobManager.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow,
|
||||
Stop,
|
||||
Refresh,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Schedule,
|
||||
ExpandMore,
|
||||
Analytics,
|
||||
DataUsage,
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface Job {
|
||||
job_id: string;
|
||||
job_type: string;
|
||||
user_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
message: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BackgroundJobManagerProps {
|
||||
siteUrl?: string;
|
||||
days?: number;
|
||||
onJobCompleted?: (job: Job) => void;
|
||||
}
|
||||
|
||||
const BackgroundJobManager: React.FC<BackgroundJobManagerProps> = ({
|
||||
siteUrl = 'https://www.alwrity.com/',
|
||||
days = 30,
|
||||
onJobCompleted,
|
||||
}) => {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||
|
||||
// Fetch user jobs
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/background-jobs/user-jobs?limit=10');
|
||||
if (response.data.success) {
|
||||
setJobs(response.data.data.jobs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching jobs:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create Bing comprehensive insights job
|
||||
const createComprehensiveInsightsJob = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/api/background-jobs/bing/comprehensive-insights?site_url=${encodeURIComponent(siteUrl)}&days=${days}`
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const jobId = response.data.data.job_id;
|
||||
console.log('✅ Comprehensive insights job created:', jobId);
|
||||
|
||||
// Refresh jobs list
|
||||
await fetchJobs();
|
||||
|
||||
// Show success message
|
||||
alert(`Background job created successfully! Job ID: ${jobId}\n\nThis will generate comprehensive Bing insights in the background. Check the job status below for progress.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating comprehensive insights job:', error);
|
||||
alert('Failed to create background job. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create Bing data collection job
|
||||
const createDataCollectionJob = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/api/background-jobs/bing/data-collection?site_url=${encodeURIComponent(siteUrl)}&days_back=${days}`
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const jobId = response.data.data.job_id;
|
||||
console.log('✅ Data collection job created:', jobId);
|
||||
|
||||
// Refresh jobs list
|
||||
await fetchJobs();
|
||||
|
||||
alert(`Background data collection job created successfully! Job ID: ${jobId}\n\nThis will collect fresh data from Bing API in the background.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating data collection job:', error);
|
||||
alert('Failed to create data collection job. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel job
|
||||
const cancelJob = async (jobId: string) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/background-jobs/cancel/${jobId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ Job cancelled:', jobId);
|
||||
await fetchJobs();
|
||||
alert('Job cancelled successfully');
|
||||
} else {
|
||||
alert(response.data.message || 'Failed to cancel job');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling job:', error);
|
||||
alert('Failed to cancel job. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// View job details
|
||||
const viewJobDetails = async (jobId: string) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/background-jobs/status/${jobId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setSelectedJob(response.data.data);
|
||||
setJobDialogOpen(true);
|
||||
|
||||
// Call onJobCompleted if job is completed
|
||||
if (response.data.data.status === 'completed' && onJobCompleted) {
|
||||
onJobCompleted(response.data.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching job details:', error);
|
||||
alert('Failed to fetch job details');
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'failed': return 'error';
|
||||
case 'running': return 'primary';
|
||||
case 'pending': return 'warning';
|
||||
case 'cancelled': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle />;
|
||||
case 'failed': return <ErrorIcon />;
|
||||
case 'running': return <CircularProgress size={16} />;
|
||||
case 'pending': return <Schedule />;
|
||||
case 'cancelled': return <Stop />;
|
||||
default: return <Schedule />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format job type
|
||||
const formatJobType = (jobType: string) => {
|
||||
switch (jobType) {
|
||||
case 'bing_comprehensive_insights': return 'Bing Comprehensive Insights';
|
||||
case 'bing_data_collection': return 'Bing Data Collection';
|
||||
case 'analytics_refresh': return 'Analytics Refresh';
|
||||
default: return jobType;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for job updates
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
|
||||
// Poll every 5 seconds for running jobs
|
||||
const interval = setInterval(() => {
|
||||
const hasRunningJobs = jobs.some(job => job.status === 'running' || job.status === 'pending');
|
||||
if (hasRunningJobs) {
|
||||
fetchJobs();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchJobs, jobs]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Action Buttons */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Background Job Actions
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Run expensive operations in the background to avoid timeouts and improve user experience.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Analytics />}
|
||||
onClick={createComprehensiveInsightsJob}
|
||||
disabled={loading}
|
||||
color="primary"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Generate Comprehensive Bing Insights'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DataUsage />}
|
||||
onClick={createDataCollectionJob}
|
||||
disabled={loading}
|
||||
color="secondary"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Collect Fresh Bing Data'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchJobs}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jobs List */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Background Jobs
|
||||
</Typography>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No background jobs found. Create a job using the buttons above.
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
{jobs.map((job) => (
|
||||
<Accordion key={job.job_id} sx={{ mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(job.status)}
|
||||
<Chip
|
||||
label={job.status.toUpperCase()}
|
||||
color={getStatusColor(job.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ flexGrow: 1 }}>
|
||||
{formatJobType(job.job_type)}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Progress Bar */}
|
||||
{(job.status === 'running' || job.status === 'pending') && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Progress: {job.progress}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={job.progress} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Job Message */}
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Status:</strong> {job.message}
|
||||
</Typography>
|
||||
|
||||
{/* Job Details */}
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Job ID:</strong> {job.job_id}
|
||||
</Typography>
|
||||
|
||||
{job.started_at && (
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Started:</strong> {new Date(job.started_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{job.completed_at && (
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Completed:</strong> {new Date(job.completed_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{job.error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
<strong>Error:</strong> {job.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => viewJobDetails(job.job_id)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
|
||||
{(job.status === 'pending' || job.status === 'running') && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => cancelJob(job.job_id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Job Details Dialog */}
|
||||
<Dialog
|
||||
open={jobDialogOpen}
|
||||
onClose={() => setJobDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Job Details - {selectedJob?.job_id}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedJob && (
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Type:</strong> {formatJobType(selectedJob.job_type)}
|
||||
</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Status:</strong> {selectedJob.status.toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Message:</strong> {selectedJob.message}
|
||||
</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Progress:</strong> {selectedJob.progress}%
|
||||
</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Created:</strong> {new Date(selectedJob.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
|
||||
{selectedJob.started_at && (
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Started:</strong> {new Date(selectedJob.started_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{selectedJob.completed_at && (
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<strong>Completed:</strong> {new Date(selectedJob.completed_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{selectedJob.result && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Results:
|
||||
</Typography>
|
||||
<pre style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '16px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '400px'
|
||||
}}>
|
||||
{JSON.stringify(selectedJob.result, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedJob.error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<strong>Error:</strong> {selectedJob.error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setJobDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundJobManager;
|
||||
746
frontend/src/components/shared/BingInsightsCard.tsx
Normal file
746
frontend/src/components/shared/BingInsightsCard.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
MouseOutlined,
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Insights,
|
||||
Lightbulb,
|
||||
Assessment,
|
||||
Refresh,
|
||||
ExpandMore,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Warning,
|
||||
Star,
|
||||
Speed,
|
||||
Analytics,
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface BingInsightsCardProps {
|
||||
siteUrl?: string;
|
||||
days?: number;
|
||||
onInsightsLoaded?: (insights: any) => void;
|
||||
insights?: {
|
||||
performance?: PerformanceInsights;
|
||||
seo?: SEOInsights;
|
||||
recommendations?: Recommendations;
|
||||
last_analyzed?: string;
|
||||
};
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface PerformanceInsights {
|
||||
performance_summary: {
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
avg_ctr: number;
|
||||
total_queries: number;
|
||||
};
|
||||
trends: {
|
||||
status?: string;
|
||||
message?: string;
|
||||
ctr_trend?: {
|
||||
current: number;
|
||||
previous: number;
|
||||
change_percent: number;
|
||||
direction: string;
|
||||
};
|
||||
clicks_trend?: {
|
||||
current: number;
|
||||
previous: number;
|
||||
change_percent: number;
|
||||
direction: string;
|
||||
};
|
||||
trend_strength?: string;
|
||||
};
|
||||
performance_indicators: {
|
||||
ctr_score?: number;
|
||||
volume_score?: number;
|
||||
consistency_score?: number;
|
||||
overall_score?: number;
|
||||
performance_level: string;
|
||||
traffic_quality?: string;
|
||||
growth_potential?: string;
|
||||
};
|
||||
insights: string[];
|
||||
error?: string; // Add error property for error handling
|
||||
}
|
||||
|
||||
interface SEOInsights {
|
||||
query_analysis: {
|
||||
total_queries: number;
|
||||
brand_queries: {
|
||||
count: number;
|
||||
clicks: number;
|
||||
percentage: number;
|
||||
};
|
||||
non_brand_queries: {
|
||||
count: number;
|
||||
clicks: number;
|
||||
percentage: number;
|
||||
};
|
||||
query_length_distribution: {
|
||||
short_queries: number;
|
||||
long_queries: number;
|
||||
average_length: number;
|
||||
};
|
||||
top_categories: Record<string, number>;
|
||||
};
|
||||
content_opportunities: Array<{
|
||||
query: string;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
opportunity: string;
|
||||
priority: string;
|
||||
}>;
|
||||
technical_insights: {
|
||||
average_position: number;
|
||||
average_ctr: number;
|
||||
position_distribution: {
|
||||
top_3: number;
|
||||
top_10: number;
|
||||
page_2_plus: number;
|
||||
};
|
||||
ctr_distribution: {
|
||||
excellent: number;
|
||||
good: number;
|
||||
poor: number;
|
||||
};
|
||||
};
|
||||
seo_recommendations: Array<{
|
||||
type: string;
|
||||
priority: string;
|
||||
recommendation: string;
|
||||
action: string;
|
||||
}>;
|
||||
error?: string; // Add error property for error handling
|
||||
}
|
||||
|
||||
interface Recommendations {
|
||||
immediate_actions: Array<{
|
||||
action: string;
|
||||
priority: string;
|
||||
description: string;
|
||||
}>;
|
||||
content_optimization: Array<{
|
||||
query: string;
|
||||
opportunity: string;
|
||||
priority: string;
|
||||
}>;
|
||||
technical_improvements: Array<{
|
||||
issue: string;
|
||||
solution: string;
|
||||
priority: string;
|
||||
}>;
|
||||
long_term_strategy: Array<{
|
||||
strategy: string;
|
||||
timeline: string;
|
||||
expected_impact: string;
|
||||
}>;
|
||||
priority_score: Record<string, number>;
|
||||
error?: string; // Add error property for error handling
|
||||
}
|
||||
|
||||
const BingInsightsCard: React.FC<BingInsightsCardProps> = ({
|
||||
siteUrl = 'https://www.alwrity.com/',
|
||||
days = 30,
|
||||
onInsightsLoaded,
|
||||
insights: propInsights,
|
||||
loading: propLoading,
|
||||
error: propError,
|
||||
}) => {
|
||||
const [internalLoading, setInternalLoading] = useState(!propInsights);
|
||||
const [internalError, setInternalError] = useState<string | null>(null);
|
||||
const [internalInsights, setInternalInsights] = useState<{
|
||||
performance?: PerformanceInsights;
|
||||
seo?: SEOInsights;
|
||||
recommendations?: Recommendations;
|
||||
last_analyzed?: string;
|
||||
}>({});
|
||||
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Use props if available, otherwise use internal state
|
||||
const loading = propLoading !== undefined ? propLoading : internalLoading;
|
||||
const error = propError !== undefined ? propError : internalError;
|
||||
const insights = propInsights || internalInsights;
|
||||
|
||||
const loadInsights = useCallback(async () => {
|
||||
// Only load if we don't have insights passed as props
|
||||
if (propInsights) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the API call to prevent rapid successive requests
|
||||
debounceTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setInternalLoading(true);
|
||||
setInternalError(null);
|
||||
|
||||
const response = await apiClient.get('/api/bing-insights/comprehensive', {
|
||||
params: { site_url: siteUrl, days }
|
||||
});
|
||||
|
||||
console.log('Raw Bing insights response:', response.data.data);
|
||||
|
||||
// The API response structure is directly the insights data (no metrics wrapper)
|
||||
const insightsData = response.data.data;
|
||||
|
||||
console.log('Insights data structure:', insightsData);
|
||||
setInternalInsights(insightsData);
|
||||
onInsightsLoaded?.(insightsData);
|
||||
|
||||
} catch (err: any) {
|
||||
setInternalError(err.response?.data?.detail || 'Failed to load Bing insights');
|
||||
} finally {
|
||||
setInternalLoading(false);
|
||||
}
|
||||
}, 300); // 300ms debounce
|
||||
}, [siteUrl, days, onInsightsLoaded, propInsights]);
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const getChangeColor = (change: number) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getChangeIcon = (change: number) => {
|
||||
if (change > 0) return <TrendingUp />;
|
||||
if (change < 0) return <TrendingDown />;
|
||||
return <TrendingUp style={{ transform: 'rotate(90deg)' }} />;
|
||||
};
|
||||
|
||||
const getPerformanceLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'excellent': return 'success';
|
||||
case 'good': return 'info';
|
||||
case 'fair': return 'warning';
|
||||
case 'needs_improvement': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Only load insights if we don't have them passed as props
|
||||
if (!propInsights) {
|
||||
loadInsights();
|
||||
}
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [loadInsights, propInsights]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" minHeight="200px">
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Loading Bing insights...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Alert severity="error" action={
|
||||
<IconButton color="inherit" size="small" onClick={loadInsights}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h6" component="h2" display="flex" alignItems="center">
|
||||
<Search sx={{ mr: 1 }} />
|
||||
Bing Webmaster Insights
|
||||
</Typography>
|
||||
<IconButton onClick={loadInsights} size="small">
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Connection Status and Basic Metrics */}
|
||||
<Card sx={{ mb: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle1" gutterBottom display="flex" alignItems="center">
|
||||
<CheckCircle sx={{ mr: 1, color: 'success.main' }} />
|
||||
Connection Status
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(insights.performance?.performance_summary?.total_clicks || 0)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Clicks
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(insights.performance?.performance_summary?.total_impressions || 0)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Impressions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" color="primary">
|
||||
{(insights.performance?.performance_summary?.avg_ctr || 0).toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Average CTR
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(insights.performance?.performance_summary?.total_queries || 0)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Queries
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
{/* Performance Insights */}
|
||||
{insights.performance && !insights.performance.error && insights.performance.performance_indicators && insights.performance.performance_summary && (
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Assessment sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1">Performance Analysis</Typography>
|
||||
<Chip
|
||||
label={insights.performance?.performance_indicators?.performance_level || 'Unknown'}
|
||||
color={getPerformanceLevelColor(insights.performance?.performance_indicators?.performance_level || 'Unknown')}
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{/* Performance Summary */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Performance Summary</Typography>
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Total Clicks:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatNumber(insights.performance.performance_summary.total_clicks || 0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Total Impressions:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatNumber(insights.performance.performance_summary.total_impressions || 0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Average CTR:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{(insights.performance.performance_summary.avg_ctr || 0).toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Total Queries:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatNumber(insights.performance.performance_summary.total_queries || 0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Performance Indicators */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Performance Indicators</Typography>
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Performance Level:</Typography>
|
||||
<Chip
|
||||
label={insights.performance.performance_indicators.performance_level || 'Unknown'}
|
||||
color={getPerformanceLevelColor(insights.performance.performance_indicators.performance_level || 'Unknown')}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Traffic Quality:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.performance.performance_indicators.traffic_quality || 'Unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Growth Potential:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.performance.performance_indicators.growth_potential || 'Unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Legacy scores if available */}
|
||||
{insights.performance.performance_indicators.ctr_score !== undefined && (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" mb={0.5}>
|
||||
<Typography variant="body2">CTR Score:</Typography>
|
||||
<Typography variant="body2">{insights.performance.performance_indicators.ctr_score || 0}/100</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={insights.performance.performance_indicators.ctr_score || 0}
|
||||
color={(insights.performance.performance_indicators.ctr_score || 0) > 70 ? 'success' : 'primary'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Trends */}
|
||||
{insights.performance.trends && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>Trends</Typography>
|
||||
{insights.performance.trends.status === 'insufficient_data' ? (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{insights.performance.trends.message || 'Detailed analytics data not available for trend analysis'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{insights.performance.trends.ctr_trend && (
|
||||
<Grid item xs={6}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getChangeIcon(insights.performance.trends.ctr_trend.change_percent || 0)}
|
||||
<Typography variant="body2">CTR Trend:</Typography>
|
||||
<Chip
|
||||
label={`${(insights.performance.trends.ctr_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.ctr_trend.change_percent || 0}%`}
|
||||
color={getChangeColor(insights.performance.trends.ctr_trend.change_percent || 0)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
{insights.performance.trends.clicks_trend && (
|
||||
<Grid item xs={6}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getChangeIcon(insights.performance.trends.clicks_trend.change_percent || 0)}
|
||||
<Typography variant="body2">Clicks Trend:</Typography>
|
||||
<Chip
|
||||
label={`${(insights.performance.trends.clicks_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.clicks_trend.change_percent || 0}%`}
|
||||
color={getChangeColor(insights.performance.trends.clicks_trend.change_percent || 0)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Performance Insights */}
|
||||
{insights.performance.insights && insights.performance.insights.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>Key Insights</Typography>
|
||||
<List dense>
|
||||
{insights.performance.insights.map((insight, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Lightbulb color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={insight} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Performance Error Fallback */}
|
||||
{insights.performance && insights.performance.error && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Performance insights unavailable: {insights.performance.error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* SEO Insights */}
|
||||
{insights.seo && !insights.seo.error && insights.seo.query_analysis && (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Analytics sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1">SEO Analysis</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{/* Query Analysis */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Query Analysis</Typography>
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Total Queries:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.query_analysis?.total_queries || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Brand Queries:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.query_analysis?.brand_queries?.percentage || 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Non-Brand Queries:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.query_analysis?.non_brand_queries?.percentage || 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Avg Query Length:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.query_analysis?.query_length_distribution?.average_length || 0} chars
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Technical Insights */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Technical Performance</Typography>
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Avg Position:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.technical_insights?.average_position !== undefined
|
||||
? insights.seo.technical_insights.average_position
|
||||
: 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Avg CTR:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{(insights.seo?.technical_insights?.average_ctr || 0).toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Top 3 Positions:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.technical_insights?.position_distribution?.top_3 || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="body2">Top 10 Positions:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{insights.seo?.technical_insights?.position_distribution?.top_10 || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Content Opportunities */}
|
||||
{insights.seo?.content_opportunities && insights.seo.content_opportunities.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>Content Opportunities</Typography>
|
||||
<List dense>
|
||||
{insights.seo.content_opportunities.slice(0, 3).map((opportunity, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Star color="warning" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={opportunity.query}
|
||||
secondary={`${opportunity.impressions} impressions, ${opportunity.ctr.toFixed(2)}% CTR - ${opportunity.opportunity}`}
|
||||
/>
|
||||
<Chip
|
||||
label={opportunity.priority}
|
||||
color={getPriorityColor(opportunity.priority)}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* SEO Recommendations */}
|
||||
{insights.seo.seo_recommendations && insights.seo.seo_recommendations.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>SEO Recommendations</Typography>
|
||||
<List dense>
|
||||
{insights.seo.seo_recommendations.map((rec, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Lightbulb color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={rec.recommendation}
|
||||
secondary={rec.action}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getPriorityColor(rec.priority)}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* SEO Error Fallback */}
|
||||
{insights.seo && insights.seo.error && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
SEO insights unavailable: {insights.seo.error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{insights.recommendations && (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Lightbulb sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1">Actionable Recommendations</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{/* Immediate Actions */}
|
||||
{insights.recommendations.immediate_actions && insights.recommendations.immediate_actions.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Immediate Actions</Typography>
|
||||
<List dense>
|
||||
{insights.recommendations.immediate_actions.map((action, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Speed color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={action.action}
|
||||
secondary={action.description}
|
||||
/>
|
||||
<Chip
|
||||
label={action.priority}
|
||||
color={getPriorityColor(action.priority)}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Long-term Strategy */}
|
||||
{insights.recommendations.long_term_strategy && insights.recommendations.long_term_strategy.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Long-term Strategy</Typography>
|
||||
<List dense>
|
||||
{insights.recommendations.long_term_strategy.map((strategy, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<TrendingUp color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={strategy.strategy}
|
||||
secondary={`${strategy.timeline} - ${strategy.expected_impact}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Last Updated Information */}
|
||||
{insights.last_analyzed && (
|
||||
<Box mt={2} p={1} bgcolor="grey.50" borderRadius={1}>
|
||||
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center">
|
||||
<Assessment sx={{ mr: 0.5, fontSize: 14 }} />
|
||||
Last analyzed: {new Date(insights.last_analyzed).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BingInsightsCard;
|
||||
@@ -3,6 +3,7 @@ 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 { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
@@ -403,6 +404,10 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{rightContent}
|
||||
|
||||
{/* Usage Dashboard - Show API usage statistics */}
|
||||
<UsageDashboard compact={true} />
|
||||
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
501
frontend/src/components/shared/PlatformAnalytics.tsx
Normal file
501
frontend/src/components/shared/PlatformAnalytics.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
MouseOutlined,
|
||||
Search,
|
||||
Web,
|
||||
Refresh,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
import { PlatformAnalytics as PlatformAnalyticsType, AnalyticsSummary, PlatformConnectionStatus } from '../../api/analytics';
|
||||
import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
|
||||
import BingInsightsCard from './BingInsightsCard';
|
||||
import BackgroundJobManager from './BackgroundJobManager';
|
||||
|
||||
interface PlatformAnalyticsComponentProps {
|
||||
platforms?: string[];
|
||||
showSummary?: boolean;
|
||||
refreshInterval?: number; // in milliseconds, 0 = no auto-refresh
|
||||
onDataLoaded?: (data: any) => void;
|
||||
onRefreshReady?: (refreshFn: () => Promise<void>) => void; // Expose refresh function to parent
|
||||
}
|
||||
|
||||
const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
|
||||
platforms,
|
||||
showSummary = true,
|
||||
refreshInterval = 0,
|
||||
onDataLoaded,
|
||||
onRefreshReady,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [analyticsData, setAnalyticsData] = useState<Record<string, PlatformAnalyticsType>>({});
|
||||
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
|
||||
const [, setPlatformStatus] = useState<Record<string, PlatformConnectionStatus>>({});
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Load platform connection status
|
||||
const statusResponse = await cachedAnalyticsAPI.getPlatformStatus();
|
||||
setPlatformStatus(statusResponse.platforms);
|
||||
|
||||
// Load analytics data
|
||||
const analyticsResponse = await cachedAnalyticsAPI.getAnalyticsData(platforms);
|
||||
setAnalyticsData(analyticsResponse.data as Record<string, PlatformAnalyticsType>);
|
||||
setSummary(analyticsResponse.summary);
|
||||
setLastUpdated(new Date());
|
||||
|
||||
if (onDataLoaded) {
|
||||
onDataLoaded({
|
||||
analytics: analyticsResponse.data,
|
||||
summary: analyticsResponse.summary,
|
||||
status: statusResponse.platforms,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Error loading analytics data:', err);
|
||||
let errorMessage = 'Failed to load analytics data';
|
||||
if (err instanceof Error) {
|
||||
errorMessage = (err as Error).message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [platforms, onDataLoaded]);
|
||||
|
||||
// Method to force refresh (bypass cache)
|
||||
const forceRefresh = useCallback(async () => {
|
||||
console.log('🔄 PlatformAnalytics: Force refresh requested');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Clear cache and force fresh data
|
||||
await cachedAnalyticsAPI.forceRefreshAnalyticsData(platforms);
|
||||
|
||||
// Reload data
|
||||
await loadData();
|
||||
|
||||
console.log('✅ PlatformAnalytics: Force refresh completed');
|
||||
} catch (err) {
|
||||
console.error('❌ PlatformAnalytics: Force refresh failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [platforms, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Set up auto-refresh if interval is specified
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
if (refreshInterval > 0) {
|
||||
interval = setInterval(loadData, refreshInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [platforms, refreshInterval, loadData]);
|
||||
|
||||
// Expose refresh function to parent component
|
||||
useEffect(() => {
|
||||
if (onRefreshReady) {
|
||||
onRefreshReady(forceRefresh);
|
||||
}
|
||||
}, [onRefreshReady, forceRefresh]);
|
||||
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
switch (platform.toLowerCase()) {
|
||||
case 'gsc':
|
||||
return <Search color="primary" />;
|
||||
case 'wix':
|
||||
return <Web color="secondary" />;
|
||||
case 'wordpress':
|
||||
return <Web color="info" />;
|
||||
case 'bing':
|
||||
return <Search color="primary" />;
|
||||
default:
|
||||
return <Web />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'partial':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle color="success" fontSize="small" />;
|
||||
case 'error':
|
||||
return <ErrorIcon color="error" fontSize="small" />;
|
||||
case 'partial':
|
||||
return <Warning color="warning" fontSize="small" />;
|
||||
default:
|
||||
return <Info fontSize="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const renderMetricsCard = (platform: string, data: PlatformAnalyticsType) => {
|
||||
const metrics = data.metrics;
|
||||
|
||||
return (
|
||||
<Card key={platform} sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getPlatformIcon(platform)}
|
||||
<Typography variant="h6" component="div">
|
||||
{platform.toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(data.status)}
|
||||
<Chip
|
||||
label={data.status}
|
||||
color={getStatusColor(data.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{data.status === 'success' && (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
{metrics.total_clicks !== undefined && (
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<MouseOutlined color="primary" sx={{ fontSize: 32, mb: 1 }} />
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(metrics.total_clicks)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Clicks
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{metrics.total_impressions !== undefined && (
|
||||
<Grid item xs={6}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Visibility color="secondary" sx={{ fontSize: 32, mb: 1 }} />
|
||||
<Typography variant="h4" color="secondary">
|
||||
{formatNumber(metrics.total_impressions)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Impressions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{metrics.avg_ctr !== undefined && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2">CTR</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{metrics.avg_ctr}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(metrics.avg_ctr * 10, 100)}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metrics.avg_position !== undefined && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2">Avg Position</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{metrics.avg_position.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.max(0, 100 - (metrics.avg_position - 1) * 5)}
|
||||
color="secondary"
|
||||
sx={{ height: 6, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metrics.top_queries && metrics.top_queries.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Top Queries
|
||||
</Typography>
|
||||
<List dense>
|
||||
{metrics.top_queries.slice(0, 3).map((query, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{index + 1}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={query.query}
|
||||
secondary={`${query.clicks} clicks • ${query.ctr.toFixed(1)}% CTR`}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.status === 'error' && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{data.error_message || 'Failed to load analytics data'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data.status === 'partial' && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{data.error_message || 'Limited analytics data available'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Last updated: {data.last_updated ? new Date(data.last_updated).toLocaleString() : 'Never'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSummaryCard = () => {
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
Analytics Summary
|
||||
</Typography>
|
||||
<IconButton onClick={forceRefresh} disabled={loading} title="Force refresh (bypass cache)">
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
{summary.connected_platforms}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Connected Platforms
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="secondary">
|
||||
{formatNumber(summary.total_clicks)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Clicks
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info">
|
||||
{formatNumber(summary.total_impressions)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Impressions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success">
|
||||
{summary.overall_ctr}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Overall CTR
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{lastUpdated && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2, textAlign: 'center' }}>
|
||||
Last refreshed: {lastUpdated.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Loading analytics data...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{showSummary && renderSummaryCard()}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{Object.entries(analyticsData)
|
||||
.filter(([platform]) => platform.toLowerCase() !== 'wordpress') // Exclude WordPress analytics
|
||||
.map(([platform, data]) => (
|
||||
<Grid item xs={12} sm={6} lg={4} key={platform}>
|
||||
{renderMetricsCard(platform, data)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Background Job Manager */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<BackgroundJobManager
|
||||
siteUrl="https://www.alwrity.com/"
|
||||
days={30}
|
||||
onJobCompleted={(job) => {
|
||||
console.log('🎉 Background job completed:', job);
|
||||
// Refresh analytics data when job completes
|
||||
forceRefresh();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Debug Section - Show data structure for all platforms */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Debug: Platform Data Structures
|
||||
</Typography>
|
||||
{Object.entries(analyticsData).map(([platform, data]) => (
|
||||
<Box key={platform} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{platform.toUpperCase()} Data:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Bing Insights Card - Show when Bing is connected */}
|
||||
{analyticsData.bing && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Debug: Bing data structure: {JSON.stringify(analyticsData.bing, null, 2)}
|
||||
</Typography>
|
||||
{analyticsData.bing.metrics?.connection_status === 'connected' && (
|
||||
<BingInsightsCard
|
||||
siteUrl={
|
||||
analyticsData.bing.metrics?.sites?.[0]?.Url ||
|
||||
analyticsData.bing.metrics?.sites?.[0]?.url ||
|
||||
'https://www.alwrity.com/'
|
||||
}
|
||||
days={30}
|
||||
insights={analyticsData.bing.metrics?.insights}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onInsightsLoaded={(insights) => {
|
||||
console.log('Bing insights loaded:', insights);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Object.keys(analyticsData).length === 0 && (
|
||||
<Alert severity="info">
|
||||
No analytics data available. Connect your platforms to see analytics insights.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformAnalytics;
|
||||
357
frontend/src/components/shared/UsageDashboard.tsx
Normal file
357
frontend/src/components/shared/UsageDashboard.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Typography,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Refresh,
|
||||
MoreVert,
|
||||
Dashboard
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { useSubscription } from '../../contexts/SubscriptionContext';
|
||||
|
||||
interface UsageStats {
|
||||
total_calls: number;
|
||||
total_cost: number;
|
||||
usage_status: string;
|
||||
provider_breakdown: Record<string, {
|
||||
calls: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UsageLimits {
|
||||
limits: {
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
mistral_calls: number;
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
monthly_cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
current_usage: UsageStats;
|
||||
limits: UsageLimits;
|
||||
projections: {
|
||||
projected_monthly_cost: number;
|
||||
cost_limit: number;
|
||||
projected_usage_percentage: number;
|
||||
};
|
||||
summary: {
|
||||
total_api_calls_this_month: number;
|
||||
total_cost_this_month: number;
|
||||
usage_status: string;
|
||||
unread_alerts: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UsageDashboardProps {
|
||||
compact?: boolean;
|
||||
showFullDashboard?: boolean;
|
||||
}
|
||||
|
||||
const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
compact = true,
|
||||
showFullDashboard = false
|
||||
}) => {
|
||||
const { subscription } = useSubscription();
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const userId = localStorage.getItem('user_id');
|
||||
|
||||
const fetchUsageData = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/api/subscription/dashboard/${userId}`);
|
||||
setDashboardData(response.data.data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
console.error('Error fetching usage data:', err);
|
||||
setError('Failed to load usage data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsageData();
|
||||
}, [userId]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchUsageData();
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleViewFullDashboard = () => {
|
||||
handleMenuClose();
|
||||
window.open('/billing', '_blank');
|
||||
};
|
||||
|
||||
const getUsageColor = (used: number, limit: number) => {
|
||||
const percentage = (used / limit) * 100;
|
||||
if (percentage >= 90) return '#f44336'; // Red
|
||||
if (percentage >= 75) return '#ff9800'; // Orange
|
||||
if (percentage >= 50) return '#ffeb3b'; // Yellow
|
||||
return '#4caf50'; // Green
|
||||
};
|
||||
|
||||
const getUsageStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
|
||||
case 'warning': return <Warning sx={{ fontSize: 16, color: '#ff9800' }} />;
|
||||
case 'limit_exceeded': return <Warning sx={{ fontSize: 16, color: '#f44336' }} />;
|
||||
default: return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderDisplayName = (provider: string) => {
|
||||
const names: Record<string, string> = {
|
||||
'gemini': 'Gemini',
|
||||
'openai': 'OpenAI',
|
||||
'anthropic': 'Claude',
|
||||
'mistral': 'Mistral',
|
||||
'tavily': 'Tavily',
|
||||
'serper': 'Serper',
|
||||
'metaphor': 'Metaphor',
|
||||
'firecrawl': 'Firecrawl',
|
||||
'stability': 'Stability'
|
||||
};
|
||||
return names[provider] || provider;
|
||||
};
|
||||
|
||||
if (!subscription || !dashboardData) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Loading usage...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ py: 0.5 }}>
|
||||
<Typography variant="caption">{error}</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact view - show key metrics as chips
|
||||
const totalCalls = dashboardData.summary.total_api_calls_this_month;
|
||||
const totalCost = dashboardData.summary.total_cost_this_month;
|
||||
const monthlyLimit = dashboardData.limits.limits.monthly_cost;
|
||||
const usagePercentage = (totalCost / monthlyLimit) * 100;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Total API Calls */}
|
||||
<Tooltip title={`${totalCalls.toLocaleString()} API calls this month`}>
|
||||
<Chip
|
||||
icon={getUsageStatusIcon(dashboardData.summary.usage_status)}
|
||||
label={`${totalCalls.toLocaleString()}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'rgba(33, 150, 243, 0.1)',
|
||||
borderColor: '#2196f3',
|
||||
color: '#1976d2',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
color: '#2196f3'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Monthly Cost */}
|
||||
<Tooltip title={`$${totalCost.toFixed(2)} of $${monthlyLimit} monthly limit`}>
|
||||
<Chip
|
||||
icon={<TrendingUp sx={{ fontSize: 14 }} />}
|
||||
label={`$${totalCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}20`,
|
||||
borderColor: getUsageColor(totalCost, monthlyLimit),
|
||||
color: getUsageColor(totalCost, monthlyLimit),
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
color: getUsageColor(totalCost, monthlyLimit)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Usage Progress */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 60 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(usagePercentage, 100)}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'rgba(0,0,0,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: getUsageColor(totalCost, monthlyLimit),
|
||||
borderRadius: 3
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
|
||||
{usagePercentage.toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Tooltip title="Refresh usage data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
|
||||
}}
|
||||
>
|
||||
<Refresh sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* More Options */}
|
||||
<Tooltip title="Usage options">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
|
||||
}}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem onClick={handleViewFullDashboard}>
|
||||
<Dashboard sx={{ mr: 1, fontSize: 18 }} />
|
||||
View Full Dashboard
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRefresh}>
|
||||
<Refresh sx={{ mr: 1, fontSize: 18 }} />
|
||||
Refresh Data
|
||||
</MenuItem>
|
||||
{lastUpdated && (
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Full dashboard view (for dedicated usage page)
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Usage Dashboard
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
|
||||
{/* Total Calls */}
|
||||
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Total API Calls
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary">
|
||||
{dashboardData.summary.total_api_calls_this_month.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Total Cost */}
|
||||
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Monthly Cost
|
||||
</Typography>
|
||||
<Typography variant="h4" color="secondary">
|
||||
${dashboardData.summary.total_cost_this_month.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
of ${dashboardData.limits.limits.monthly_cost} limit
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Usage by Provider */}
|
||||
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Usage by Provider
|
||||
</Typography>
|
||||
{Object.entries(dashboardData.current_usage.provider_breakdown).map(([provider, stats]) => (
|
||||
<Box key={provider} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{getProviderDisplayName(provider)}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{stats.calls.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageDashboard;
|
||||
Reference in New Issue
Block a user