341 lines
No EOL
14 KiB
TypeScript
341 lines
No EOL
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { Bell, CheckCircle, XCircle, Clock, Loader2, ChevronDown, ChevronUp, X } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { useTask, Task } from '@/contexts/task-context'
|
|
|
|
export function TaskNotificationMenu() {
|
|
const { tasks, isFetching, isMenuOpen, isRecentTasksExpanded, cancelTask } = useTask()
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
|
|
// Sync local state with context state
|
|
useEffect(() => {
|
|
if (isRecentTasksExpanded) {
|
|
setIsExpanded(true)
|
|
}
|
|
}, [isRecentTasksExpanded])
|
|
|
|
// Don't render if menu is closed
|
|
if (!isMenuOpen) return null
|
|
|
|
const activeTasks = tasks.filter(task =>
|
|
task.status === 'pending' || task.status === 'running' || task.status === 'processing'
|
|
)
|
|
const recentTasks = tasks.filter(task =>
|
|
task.status === 'completed' || task.status === 'failed' || task.status === 'error'
|
|
).slice(0, 5) // Show last 5 completed/failed tasks
|
|
|
|
const getTaskIcon = (status: Task['status']) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return <CheckCircle className="h-4 w-4 text-green-500" />
|
|
case 'failed':
|
|
case 'error':
|
|
return <XCircle className="h-4 w-4 text-red-500" />
|
|
case 'pending':
|
|
return <Clock className="h-4 w-4 text-yellow-500" />
|
|
case 'running':
|
|
case 'processing':
|
|
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
|
default:
|
|
return <Clock className="h-4 w-4 text-gray-500" />
|
|
}
|
|
}
|
|
|
|
const getStatusBadge = (status: Task['status']) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">Completed</Badge>
|
|
case 'failed':
|
|
case 'error':
|
|
return <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">Failed</Badge>
|
|
case 'pending':
|
|
return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Pending</Badge>
|
|
case 'running':
|
|
case 'processing':
|
|
return <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">Processing</Badge>
|
|
default:
|
|
return <Badge variant="outline" className="bg-gray-500/10 text-gray-500 border-gray-500/20">Unknown</Badge>
|
|
}
|
|
}
|
|
|
|
const formatTaskProgress = (task: Task) => {
|
|
const total = task.total_files || 0
|
|
const processed = task.processed_files || 0
|
|
const successful = task.successful_files || 0
|
|
const failed = task.failed_files || 0
|
|
const running = task.running_files || 0
|
|
const pending = task.pending_files || 0
|
|
|
|
if (total > 0) {
|
|
return {
|
|
basic: `${processed}/${total} files`,
|
|
detailed: {
|
|
total,
|
|
processed,
|
|
successful,
|
|
failed,
|
|
running,
|
|
pending,
|
|
remaining: total - processed
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const formatDuration = (seconds?: number) => {
|
|
if (!seconds || seconds < 0) return null
|
|
|
|
if (seconds < 60) {
|
|
return `${Math.round(seconds)}s`
|
|
} else if (seconds < 3600) {
|
|
const mins = Math.floor(seconds / 60)
|
|
const secs = Math.round(seconds % 60)
|
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
|
|
} else {
|
|
const hours = Math.floor(seconds / 3600)
|
|
const mins = Math.floor((seconds % 3600) / 60)
|
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
|
|
}
|
|
}
|
|
|
|
const formatRelativeTime = (dateString: string) => {
|
|
// Handle different timestamp formats
|
|
let date: Date
|
|
|
|
// If it's a number (Unix timestamp), convert it
|
|
if (/^\d+$/.test(dateString)) {
|
|
const timestamp = parseInt(dateString)
|
|
// If it looks like seconds (less than 10^13), convert to milliseconds
|
|
date = new Date(timestamp < 10000000000 ? timestamp * 1000 : timestamp)
|
|
}
|
|
// If it's a decimal number (Unix timestamp with decimals)
|
|
else if (/^\d+\.\d+$/.test(dateString)) {
|
|
const timestamp = parseFloat(dateString)
|
|
// Convert seconds to milliseconds
|
|
date = new Date(timestamp * 1000)
|
|
}
|
|
// Otherwise, try to parse as ISO string or other date format
|
|
else {
|
|
date = new Date(dateString)
|
|
}
|
|
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
console.warn('Invalid date format:', dateString)
|
|
return 'Unknown time'
|
|
}
|
|
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMinutes = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffMinutes < 1) return 'Just now'
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
return `${diffDays}d ago`
|
|
}
|
|
|
|
return (
|
|
<div className="fixed top-14 right-0 z-40 w-80 h-[calc(100vh-3.5rem)] bg-background border-l border-border/40">
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-border/40">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-5 w-5 text-muted-foreground" />
|
|
<h3 className="font-semibold">Tasks</h3>
|
|
{isFetching && (
|
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
{activeTasks.length > 0 && (
|
|
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500">
|
|
{activeTasks.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Active Tasks */}
|
|
{activeTasks.length > 0 && (
|
|
<div className="p-4 space-y-3">
|
|
<h4 className="text-sm font-medium text-muted-foreground">Active Tasks</h4>
|
|
{activeTasks.map((task) => (
|
|
<Card key={task.task_id} className="bg-card/50">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
{getTaskIcon(task.status)}
|
|
Task {task.task_id.substring(0, 8)}...
|
|
</CardTitle>
|
|
</div>
|
|
<CardDescription className="text-xs">
|
|
Started {formatRelativeTime(task.created_at)}
|
|
{formatDuration(task.duration_seconds) && (
|
|
<span className="ml-2 text-muted-foreground">
|
|
• {formatDuration(task.duration_seconds)}
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
{formatTaskProgress(task) && (
|
|
<CardContent className="pt-0">
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
Progress: {formatTaskProgress(task)?.basic}
|
|
</div>
|
|
{formatTaskProgress(task)?.detailed && (
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span className="text-green-600">
|
|
{formatTaskProgress(task)?.detailed.successful} success
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span className="text-red-600">
|
|
{formatTaskProgress(task)?.detailed.failed} failed
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<span className="text-blue-600">
|
|
{formatTaskProgress(task)?.detailed.running} running
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
|
<span className="text-yellow-600">
|
|
{formatTaskProgress(task)?.detailed.pending} pending
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Cancel button in bottom right */}
|
|
{(task.status === 'pending' || task.status === 'running' || task.status === 'processing') && (
|
|
<div className="flex justify-end mt-3">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => cancelTask(task.task_id)}
|
|
className="h-7 px-3 text-xs"
|
|
title="Cancel task"
|
|
>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
)}
|
|
{/* Cancel button for tasks without progress */}
|
|
{!formatTaskProgress(task) && (task.status === 'pending' || task.status === 'running' || task.status === 'processing') && (
|
|
<CardContent className="pt-0">
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => cancelTask(task.task_id)}
|
|
className="h-7 px-3 text-xs"
|
|
title="Cancel task"
|
|
>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Tasks */}
|
|
{recentTasks.length > 0 && (
|
|
<div className="p-4 space-y-3 border-t border-border/40">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium text-muted-foreground">Recent Tasks</h4>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-3 w-3" />
|
|
) : (
|
|
<ChevronDown className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="space-y-2 transition-all duration-200">
|
|
{recentTasks.map((task) => (
|
|
<div
|
|
key={task.task_id}
|
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
|
>
|
|
{getTaskIcon(task.status)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium truncate">
|
|
Task {task.task_id.substring(0, 8)}...
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatRelativeTime(task.updated_at)}
|
|
{formatDuration(task.duration_seconds) && (
|
|
<span className="ml-2">
|
|
• {formatDuration(task.duration_seconds)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Show final results for completed tasks */}
|
|
{task.status === 'completed' && formatTaskProgress(task)?.detailed && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{formatTaskProgress(task)?.detailed.successful} success, {' '}
|
|
{formatTaskProgress(task)?.detailed.failed} failed
|
|
{(formatTaskProgress(task)?.detailed.running || 0) > 0 && (
|
|
<span>, {formatTaskProgress(task)?.detailed.running} running</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{task.status === 'failed' && task.error && (
|
|
<div className="text-xs text-red-600 mt-1 truncate">
|
|
{task.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{getStatusBadge(task.status)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{activeTasks.length === 0 && recentTasks.length === 0 && (
|
|
<div className="p-8 text-center">
|
|
<Bell className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
|
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">No tasks yet</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
Task notifications will appear here when you upload files or sync connectors.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|