task notificaton panel in frontend
This commit is contained in:
parent
a282f2a9f8
commit
0d171706e6
13 changed files with 822 additions and 626 deletions
31
frontend/components/ui/sonner.tsx
Normal file
31
frontend/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
|
@ -6409,6 +6410,16 @@
|
|||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
|
||||
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { Upload, FolderOpen, Loader2 } from "lucide-react"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
|
||||
function AdminPage() {
|
||||
const [fileUploadLoading, setFileUploadLoading] = useState(false)
|
||||
|
|
@ -14,6 +15,7 @@ function AdminPage() {
|
|||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [folderPath, setFolderPath] = useState("/app/documents/")
|
||||
const [uploadStatus, setUploadStatus] = useState<string>("")
|
||||
const { addTask } = useTask()
|
||||
|
||||
const handleFileUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -68,7 +70,7 @@ function AdminPage() {
|
|||
const result = await response.json()
|
||||
|
||||
if (response.status === 201) {
|
||||
// New flow: Got task ID, start polling
|
||||
// New flow: Got task ID, use centralized tracking
|
||||
const taskId = result.task_id || result.id
|
||||
const totalFiles = result.total_files || 0
|
||||
|
||||
|
|
@ -76,10 +78,12 @@ function AdminPage() {
|
|||
throw new Error("No task ID received from server")
|
||||
}
|
||||
|
||||
setUploadStatus(`🔄 Processing started for ${totalFiles} files... (Task ID: ${taskId})`)
|
||||
// Add task to centralized tracking
|
||||
addTask(taskId)
|
||||
|
||||
// Start polling the task status
|
||||
await pollPathTaskStatus(taskId, totalFiles)
|
||||
setUploadStatus(`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`)
|
||||
setFolderPath("")
|
||||
setPathUploadLoading(false)
|
||||
|
||||
} else if (response.ok) {
|
||||
// Original flow: Direct response with results
|
||||
|
|
@ -87,72 +91,18 @@ function AdminPage() {
|
|||
const total = result.results?.length || 0
|
||||
setUploadStatus(`Path processed successfully! ${successful}/${total} files indexed.`)
|
||||
setFolderPath("")
|
||||
setPathUploadLoading(false)
|
||||
} else {
|
||||
setUploadStatus(`Error: ${result.error || "Path upload failed"}`)
|
||||
setPathUploadLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Path upload failed"}`)
|
||||
} finally {
|
||||
setPathUploadLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pollPathTaskStatus = async (taskId: string, totalFiles: number) => {
|
||||
const maxAttempts = 120 // Poll for up to 10 minutes (120 * 5s intervals) for large batches
|
||||
let attempts = 0
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
attempts++
|
||||
|
||||
const response = await fetch(`/api/tasks/${taskId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check task status: ${response.status}`)
|
||||
}
|
||||
|
||||
const task = await response.json()
|
||||
|
||||
if (task.status === 'completed') {
|
||||
setUploadStatus(`✅ Path processing completed! ${task.successful_files}/${task.total_files} files processed successfully.`)
|
||||
setFolderPath("")
|
||||
setPathUploadLoading(false)
|
||||
|
||||
} else if (task.status === 'failed' || task.status === 'error') {
|
||||
setUploadStatus(`❌ Path processing failed: ${task.error || 'Unknown error occurred'}`)
|
||||
setPathUploadLoading(false)
|
||||
|
||||
} else if (task.status === 'pending' || task.status === 'running') {
|
||||
// Still in progress, update status and continue polling
|
||||
const processed = task.processed_files || 0
|
||||
const successful = task.successful_files || 0
|
||||
const failed = task.failed_files || 0
|
||||
|
||||
setUploadStatus(`⏳ Processing files... ${processed}/${totalFiles} processed (${successful} successful, ${failed} failed)`)
|
||||
|
||||
// Continue polling if we haven't exceeded max attempts
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000) // Poll every 5 seconds
|
||||
} else {
|
||||
setUploadStatus(`⚠️ Processing timeout after ${attempts} attempts. The task may still be running in the background.`)
|
||||
setPathUploadLoading(false)
|
||||
}
|
||||
|
||||
} else {
|
||||
setUploadStatus(`❓ Unknown task status: ${task.status}`)
|
||||
setPathUploadLoading(false)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Task polling error:', error)
|
||||
setUploadStatus(`❌ Failed to check processing status: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
setPathUploadLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling immediately
|
||||
poll()
|
||||
}
|
||||
// Remove the old pollPathTaskStatus function since we're using centralized system
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default function AuthCallbackPage() {
|
|||
const { refreshAuth } = useAuth()
|
||||
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [purpose, setPurpose] = useState<string>("app_auth")
|
||||
const hasProcessed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -32,10 +33,15 @@ export default function AuthCallbackPage() {
|
|||
const storedConnectorType = localStorage.getItem('connecting_connector_type')
|
||||
const authPurpose = localStorage.getItem('auth_purpose')
|
||||
|
||||
// Determine purpose - default to app_auth for login, data_source for connectors
|
||||
const detectedPurpose = authPurpose || (storedConnectorType?.includes('drive') ? 'data_source' : 'app_auth')
|
||||
setPurpose(detectedPurpose)
|
||||
|
||||
// Debug logging
|
||||
console.log('Auth Callback Debug:', {
|
||||
console.log('OAuth Callback Debug:', {
|
||||
urlParams: { code: !!code, state: !!state, error: errorParam },
|
||||
localStorage: { connectorId, storedConnectorType, authPurpose },
|
||||
detectedPurpose,
|
||||
fullUrl: window.location.href
|
||||
})
|
||||
|
||||
|
|
@ -47,7 +53,7 @@ export default function AuthCallbackPage() {
|
|||
}
|
||||
|
||||
if (!code || !state || !finalConnectorId) {
|
||||
console.error('Missing auth callback parameters:', {
|
||||
console.error('Missing OAuth callback parameters:', {
|
||||
code: !!code,
|
||||
state: !!state,
|
||||
finalConnectorId: !!finalConnectorId
|
||||
|
|
@ -70,30 +76,44 @@ export default function AuthCallbackPage() {
|
|||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.purpose === 'app_auth') {
|
||||
if (response.ok) {
|
||||
setStatus("success")
|
||||
|
||||
// Refresh auth context to pick up the new user
|
||||
await refreshAuth()
|
||||
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
|
||||
// Get redirect URL from login page
|
||||
const redirectTo = searchParams.get('redirect') || '/'
|
||||
|
||||
// Redirect to the original page or home
|
||||
setTimeout(() => {
|
||||
router.push(redirectTo)
|
||||
}, 2000)
|
||||
if (result.purpose === 'app_auth' || detectedPurpose === 'app_auth') {
|
||||
// App authentication - refresh auth context and redirect to home/original page
|
||||
await refreshAuth()
|
||||
|
||||
// Get redirect URL from login page
|
||||
const redirectTo = searchParams.get('redirect') || '/'
|
||||
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
|
||||
// Redirect to the original page or home
|
||||
setTimeout(() => {
|
||||
router.push(redirectTo)
|
||||
}, 2000)
|
||||
} else {
|
||||
// Connector authentication - redirect to connectors page
|
||||
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
|
||||
// Redirect to connectors page with success indicator
|
||||
setTimeout(() => {
|
||||
router.push('/connectors?oauth_success=true')
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Authentication failed')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Auth callback error:', err)
|
||||
console.error('OAuth callback error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
setStatus("error")
|
||||
|
||||
|
|
@ -107,6 +127,33 @@ export default function AuthCallbackPage() {
|
|||
handleCallback()
|
||||
}, [searchParams, router, refreshAuth])
|
||||
|
||||
// Dynamic UI content based on purpose
|
||||
const isAppAuth = purpose === 'app_auth'
|
||||
|
||||
const getTitle = () => {
|
||||
if (status === "processing") {
|
||||
return isAppAuth ? "Signing you in..." : "Connecting..."
|
||||
}
|
||||
if (status === "success") {
|
||||
return isAppAuth ? "Welcome to GenDB!" : "Connection Successful!"
|
||||
}
|
||||
if (status === "error") {
|
||||
return isAppAuth ? "Sign In Failed" : "Connection Failed"
|
||||
}
|
||||
}
|
||||
|
||||
const getDescription = () => {
|
||||
if (status === "processing") {
|
||||
return isAppAuth ? "Please wait while we complete your sign in..." : "Please wait while we complete the connection..."
|
||||
}
|
||||
if (status === "success") {
|
||||
return "You will be redirected shortly."
|
||||
}
|
||||
if (status === "error") {
|
||||
return isAppAuth ? "There was an issue signing you in." : "There was an issue with the connection."
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
|
|
@ -115,26 +162,24 @@ export default function AuthCallbackPage() {
|
|||
{status === "processing" && (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Signing you in...
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
Welcome to GenDB!
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
Sign In Failed
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === "processing" && "Please wait while we complete your sign in..."}
|
||||
{status === "success" && "You will be redirected shortly."}
|
||||
{status === "error" && "There was an issue signing you in."}
|
||||
{getDescription()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -144,12 +189,12 @@ export default function AuthCallbackPage() {
|
|||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push('/login')}
|
||||
onClick={() => router.push(isAppAuth ? '/login' : '/connectors')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
{isAppAuth ? 'Back to Login' : 'Back to Connectors'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -157,7 +202,7 @@ export default function AuthCallbackPage() {
|
|||
<div className="text-center">
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<p className="text-sm text-green-600">
|
||||
Redirecting you to the app...
|
||||
{isAppAuth ? 'Redirecting you to the app...' : 'Redirecting to connectors...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant"
|
||||
|
|
@ -65,6 +67,8 @@ function ChatPage() {
|
|||
const dragCounterRef = useRef(0)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { user } = useAuth()
|
||||
const { addTask } = useTask()
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
|
|
@ -120,7 +124,7 @@ function ChatPage() {
|
|||
console.log("Upload result:", result)
|
||||
|
||||
if (response.status === 201) {
|
||||
// New flow: Got task ID, start polling
|
||||
// New flow: Got task ID, start tracking with centralized system
|
||||
const taskId = result.task_id || result.id
|
||||
|
||||
if (!taskId) {
|
||||
|
|
@ -128,17 +132,17 @@ function ChatPage() {
|
|||
throw new Error("No task ID received from server")
|
||||
}
|
||||
|
||||
// Update message to show polling started
|
||||
// Add task to centralized tracking
|
||||
addTask(taskId)
|
||||
|
||||
// Update message to show task is being tracked
|
||||
const pollingMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `⏳ Upload initiated for **${file.name}**. Processing... (Task ID: ${taskId})`,
|
||||
content: `⏳ Upload initiated for **${file.name}**. Processing in background... (Task ID: ${taskId})`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), pollingMessage])
|
||||
|
||||
// Start polling the task status
|
||||
await pollTaskStatus(taskId, file.name)
|
||||
|
||||
} else if (response.ok) {
|
||||
// Original flow: Direct response
|
||||
|
||||
|
|
@ -175,101 +179,7 @@ function ChatPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const pollTaskStatus = async (taskId: string, filename: string) => {
|
||||
const maxAttempts = 60 // Poll for up to 5 minutes (60 * 5s intervals)
|
||||
let attempts = 0
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
attempts++
|
||||
|
||||
const response = await fetch(`/api/tasks/${taskId}`)
|
||||
console.log("Task polling response status:", response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error("Task polling failed:", response.status, errorText)
|
||||
throw new Error(`Failed to check task status: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const task = await response.json()
|
||||
console.log("Task polling result:", task)
|
||||
|
||||
// Safety check to ensure task object exists
|
||||
if (!task) {
|
||||
throw new Error("No task data received from server")
|
||||
}
|
||||
|
||||
// Update the message based on task status
|
||||
if (task.status === 'completed') {
|
||||
const successMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `✅ **${filename}** processed successfully!\n\n${task.result?.confirmation || 'Document has been added to the knowledge base.'}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), successMessage])
|
||||
|
||||
// Update response ID if available
|
||||
if (task.result?.response_id) {
|
||||
setPreviousResponseIds(prev => ({
|
||||
...prev,
|
||||
[endpoint]: task.result.response_id
|
||||
}))
|
||||
}
|
||||
|
||||
} else if (task.status === 'failed' || task.status === 'error') {
|
||||
const errorMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `❌ Processing failed for **${filename}**: ${task.error || 'Unknown error occurred'}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), errorMessage])
|
||||
|
||||
} else if (task.status === 'pending' || task.status === 'running' || task.status === 'processing') {
|
||||
// Still in progress, update message and continue polling
|
||||
const progressMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `⏳ Processing **${filename}**... (${task.status}) - Attempt ${attempts}/${maxAttempts}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), progressMessage])
|
||||
|
||||
// Continue polling if we haven't exceeded max attempts
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000) // Poll every 5 seconds
|
||||
} else {
|
||||
const timeoutMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `⚠️ Processing timeout for **${filename}**. The task may still be running in the background.`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), timeoutMessage])
|
||||
}
|
||||
|
||||
} else {
|
||||
// Unknown status
|
||||
const unknownMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `❓ Unknown status for **${filename}**: ${task.status}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), unknownMessage])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Task polling error:', error)
|
||||
const errorMessage: Message = {
|
||||
role: "assistant",
|
||||
content: `❌ Failed to check processing status for **${filename}**: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev.slice(0, -1), errorMessage])
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling immediately
|
||||
poll()
|
||||
}
|
||||
// Remove the old pollTaskStatus function since we're using centralized system
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Loader2, CheckCircle, XCircle, ArrowLeft } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function ConnectorCallbackPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshAuth } = useAuth()
|
||||
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [connectorType, setConnectorType] = useState<string | null>(null)
|
||||
const [isAppAuth, setIsAppAuth] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// Get parameters from URL
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const errorParam = searchParams.get('error')
|
||||
|
||||
// Get stored connector info
|
||||
const connectorId = localStorage.getItem('connecting_connector_id')
|
||||
const storedConnectorType = localStorage.getItem('connecting_connector_type')
|
||||
const authPurpose = localStorage.getItem('auth_purpose')
|
||||
|
||||
// Debug logging
|
||||
console.log('OAuth Callback Debug:', {
|
||||
urlParams: { code: !!code, state: !!state, error: errorParam },
|
||||
localStorage: { connectorId, storedConnectorType, authPurpose },
|
||||
fullUrl: window.location.href
|
||||
})
|
||||
|
||||
// Use state parameter as connection_id if localStorage is missing
|
||||
const finalConnectorId = connectorId || state
|
||||
const finalConnectorType = storedConnectorType || 'app_auth'
|
||||
const finalAuthPurpose = authPurpose || 'app_auth'
|
||||
|
||||
setConnectorType(finalConnectorType)
|
||||
setIsAppAuth(finalAuthPurpose === 'app_auth' || finalConnectorType === 'app_auth')
|
||||
|
||||
if (errorParam) {
|
||||
throw new Error(`OAuth error: ${errorParam}`)
|
||||
}
|
||||
|
||||
if (!code || !state || !finalConnectorId) {
|
||||
console.error('Missing OAuth callback parameters:', {
|
||||
code: !!code,
|
||||
state: !!state,
|
||||
finalConnectorId: !!finalConnectorId
|
||||
})
|
||||
throw new Error('Missing required parameters for OAuth callback')
|
||||
}
|
||||
|
||||
// Send callback data to backend
|
||||
const response = await fetch('/api/auth/callback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
connection_id: finalConnectorId,
|
||||
authorization_code: code,
|
||||
state: state
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setStatus("success")
|
||||
|
||||
if (result.purpose === 'app_auth' || isAppAuth) {
|
||||
// App authentication - refresh auth context and redirect to home
|
||||
await refreshAuth()
|
||||
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
|
||||
// Redirect to home page after app login
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 2000)
|
||||
} else {
|
||||
// Connector authentication - redirect to connectors page
|
||||
// Clean up localStorage
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
|
||||
// Redirect to connectors page after a short delay
|
||||
setTimeout(() => {
|
||||
router.push('/connectors?oauth_success=true')
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Authentication failed')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('OAuth callback error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
setStatus("error")
|
||||
|
||||
// Clean up localStorage on error too
|
||||
localStorage.removeItem('connecting_connector_id')
|
||||
localStorage.removeItem('connecting_connector_type')
|
||||
localStorage.removeItem('auth_purpose')
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, router, refreshAuth])
|
||||
|
||||
const getTitle = () => {
|
||||
if (status === "processing") {
|
||||
return isAppAuth ? "Signing In..." : "Connecting..."
|
||||
}
|
||||
if (status === "success") {
|
||||
return isAppAuth ? "Sign In Successful!" : "Connection Successful!"
|
||||
}
|
||||
if (status === "error") {
|
||||
return isAppAuth ? "Sign In Failed" : "Connection Failed"
|
||||
}
|
||||
}
|
||||
|
||||
const getDescription = () => {
|
||||
if (status === "processing") {
|
||||
return isAppAuth ? "Please wait while we sign you in..." : "Please wait while we complete the connection..."
|
||||
}
|
||||
if (status === "success") {
|
||||
return "You will be redirected shortly."
|
||||
}
|
||||
if (status === "error") {
|
||||
return isAppAuth ? "There was an issue signing you in." : "There was an issue with the connection."
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
{status === "processing" && (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
{getTitle()}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getDescription()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === "error" && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(isAppAuth ? '/' : '/connectors')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{isAppAuth ? 'Back to Home' : 'Back to Connectors'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<div className="text-center">
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<p className="text-sm text-green-600">
|
||||
{isAppAuth ? 'Redirecting to home...' : 'Redirecting to connectors...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, FileText, Download, AlertCircle } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
import { ProtectedRoute } from "@/components/protected-route"
|
||||
|
||||
interface Connector {
|
||||
|
|
@ -19,6 +20,7 @@ interface Connector {
|
|||
status: "not_connected" | "connecting" | "connected" | "error"
|
||||
type: string
|
||||
connectionId?: string // Store the active connection ID for syncing
|
||||
access_token?: string // For connectors that use OAuth
|
||||
}
|
||||
|
||||
interface ConnectorStatus {
|
||||
|
|
@ -33,88 +35,94 @@ interface ConnectorStatus {
|
|||
}>
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
processed?: number;
|
||||
added?: number;
|
||||
skipped?: number;
|
||||
errors?: number;
|
||||
error?: string;
|
||||
message?: string; // For sync started messages
|
||||
isStarted?: boolean; // For sync started state
|
||||
}
|
||||
|
||||
function ConnectorsPage() {
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const { addTask, refreshTasks } = useTask()
|
||||
const searchParams = useSearchParams()
|
||||
const [connectors, setConnectors] = useState<Connector[]>([
|
||||
{
|
||||
id: "google_drive",
|
||||
name: "Google Drive",
|
||||
description: "Connect your Google Drive to automatically sync documents",
|
||||
icon: <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
|
||||
status: "not_connected",
|
||||
type: "google_drive"
|
||||
},
|
||||
// Future connectors can be added here
|
||||
// {
|
||||
// id: "dropbox",
|
||||
// name: "Dropbox",
|
||||
// description: "Connect your Dropbox to automatically sync documents",
|
||||
// icon: <div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">D</div>,
|
||||
// status: "not_connected",
|
||||
// type: "dropbox"
|
||||
// }
|
||||
])
|
||||
const [connectors, setConnectors] = useState<Connector[]>([])
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState<string | null>(null)
|
||||
const [isSyncing, setIsSyncing] = useState<string | null>(null)
|
||||
const [syncResults, setSyncResults] = useState<{ [key: string]: any }>({})
|
||||
const [syncProgress, setSyncProgress] = useState<{ [key: string]: any }>({})
|
||||
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
|
||||
const [syncProgress, setSyncProgress] = useState<{[key: string]: number | null}>({})
|
||||
const [maxFiles, setMaxFiles] = useState<number>(10)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Function definitions first
|
||||
const checkConnectorStatuses = async () => {
|
||||
for (const connector of connectors) {
|
||||
try {
|
||||
const response = await fetch(`/api/connectors/status/${connector.type}`)
|
||||
// Initialize connectors list
|
||||
setConnectors([
|
||||
{
|
||||
id: "google_drive",
|
||||
name: "Google Drive",
|
||||
description: "Connect your Google Drive to automatically sync documents",
|
||||
icon: <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
|
||||
status: "not_connected",
|
||||
type: "google_drive"
|
||||
},
|
||||
])
|
||||
|
||||
try {
|
||||
// Check status for each connector type
|
||||
const connectorTypes = ["google_drive"]
|
||||
|
||||
for (const connectorType of connectorTypes) {
|
||||
const response = await fetch(`/api/connectors/${connectorType}/status`)
|
||||
if (response.ok) {
|
||||
const status: ConnectorStatus = await response.json()
|
||||
const isConnected = status.authenticated
|
||||
|
||||
// Find the first active connection to use for syncing
|
||||
const activeConnection = status.connections?.find(conn => conn.is_active)
|
||||
const data = await response.json()
|
||||
const connections = data.connections || []
|
||||
const activeConnection = connections.find((conn: any) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.id === connector.id
|
||||
c.type === connectorType
|
||||
? {
|
||||
...c,
|
||||
status: isConnected ? "connected" : "not_connected",
|
||||
connectionId: activeConnection?.connection_id
|
||||
connectionId: activeConnection?.connection_id
|
||||
}
|
||||
: c
|
||||
))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check status for ${connector.name}:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check connector statuses:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshConnectorStatus = async (connectorId: string) => {
|
||||
const connector = connectors.find(c => c.id === connectorId)
|
||||
if (!connector) return
|
||||
|
||||
const refreshConnectorStatus = async (connector: Connector) => {
|
||||
try {
|
||||
const response = await fetch(`/api/connectors/status/${connector.type}`)
|
||||
const response = await fetch(`/api/connectors/${connector.type}/status`)
|
||||
if (response.ok) {
|
||||
const status: ConnectorStatus = await response.json()
|
||||
const isConnected = status.authenticated
|
||||
|
||||
// Find the first active connection to use for syncing
|
||||
const activeConnection = status.connections?.find(conn => conn.is_active)
|
||||
const data = await response.json()
|
||||
const connections = data.connections || []
|
||||
const activeConnection = connections.find((conn: any) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
|
||||
setConnectors(prev => prev.map(c =>
|
||||
c.id === connectorId
|
||||
c.id === connector.id
|
||||
? {
|
||||
...c,
|
||||
status: isConnected ? "connected" : "not_connected",
|
||||
connectionId: activeConnection?.connection_id
|
||||
connectionId: activeConnection?.connection_id
|
||||
}
|
||||
: c
|
||||
))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh status for ${connector.name}:`, error)
|
||||
console.error(`Failed to refresh connector status for ${connector.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,8 +133,8 @@ function ConnectorsPage() {
|
|||
))
|
||||
|
||||
try {
|
||||
// Frontend determines the correct redirect URI using its own origin
|
||||
const redirectUri = `${window.location.origin}/connectors/callback`
|
||||
// Use the shared auth callback URL, not a separate connectors callback
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
|
||||
const response = await fetch('/api/auth/init', {
|
||||
method: 'POST',
|
||||
|
|
@ -175,169 +183,66 @@ function ConnectorsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const pollTaskStatus = async (taskId: string, connectorId: string) => {
|
||||
const maxAttempts = 120 // Poll for up to 10 minutes (120 * 5s intervals)
|
||||
let attempts = 0
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
attempts++
|
||||
|
||||
const response = await fetch(`/api/tasks/${taskId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check task status: ${response.status}`)
|
||||
}
|
||||
|
||||
const task = await response.json()
|
||||
|
||||
if (task.status === 'completed') {
|
||||
// Task completed successfully
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
processed: task.total_files || 0,
|
||||
added: task.successful_files || 0,
|
||||
skipped: (task.total_files || 0) - (task.successful_files || 0),
|
||||
errors: task.failed_files || 0
|
||||
}
|
||||
}))
|
||||
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
|
||||
setIsSyncing(null)
|
||||
|
||||
} else if (task.status === 'failed' || task.status === 'error') {
|
||||
// Task failed
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
error: task.error || 'Sync failed'
|
||||
}
|
||||
}))
|
||||
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
|
||||
setIsSyncing(null)
|
||||
|
||||
} else if (task.status === 'pending' || task.status === 'running') {
|
||||
// Still in progress, update progress and continue polling
|
||||
const processed = task.processed_files || 0
|
||||
const total = task.total_files || 0
|
||||
const successful = task.successful_files || 0
|
||||
const failed = task.failed_files || 0
|
||||
|
||||
setSyncProgress(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
status: task.status,
|
||||
processed,
|
||||
total,
|
||||
successful,
|
||||
failed
|
||||
}
|
||||
}))
|
||||
|
||||
// Continue polling if we haven't exceeded max attempts
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000) // Poll every 5 seconds
|
||||
} else {
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
error: `Sync timeout after ${attempts} attempts. The task may still be running in the background.`
|
||||
}
|
||||
}))
|
||||
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
|
||||
setIsSyncing(null)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Unknown status
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
error: `Unknown task status: ${task.status}`
|
||||
}
|
||||
}))
|
||||
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
|
||||
setIsSyncing(null)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Task polling error:', error)
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connectorId]: {
|
||||
error: error instanceof Error ? error.message : 'Failed to check sync status'
|
||||
}
|
||||
}))
|
||||
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
|
||||
setIsSyncing(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling
|
||||
await poll()
|
||||
}
|
||||
|
||||
const handleSync = async (connector: Connector) => {
|
||||
setIsSyncing(connector.id)
|
||||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||
setSyncProgress(prev => ({ ...prev, [connector.id]: null }))
|
||||
|
||||
if (!connector.connectionId) {
|
||||
console.error('No connection ID available for syncing')
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
error: 'No active connection found. Please reconnect and try again.'
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
console.error('No connection ID available for connector')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
setIsSyncing(connector.id)
|
||||
setSyncProgress(prev => ({ ...prev, [connector.id]: null })) // Clear any existing progress
|
||||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/connectors/sync', {
|
||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
connection_id: connector.connectionId,
|
||||
max_files: maxFiles
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
|
||||
if (response.status === 201 && result.task_id) {
|
||||
// Async sync started, begin polling for status
|
||||
setSyncProgress(prev => ({
|
||||
// Task-based sync, use centralized tracking
|
||||
addTask(result.task_id)
|
||||
console.log(`Sync task ${result.task_id} added to central tracking for connector ${connector.id}`)
|
||||
|
||||
// Immediately refresh task notifications to show the new task
|
||||
await refreshTasks()
|
||||
|
||||
// Show sync started message
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
status: 'pending',
|
||||
processed: 0,
|
||||
total: 0,
|
||||
successful: 0,
|
||||
failed: 0
|
||||
message: "Check task notification panel for progress",
|
||||
isStarted: true
|
||||
}
|
||||
}))
|
||||
|
||||
// Start polling for task status
|
||||
await pollTaskStatus(result.task_id, connector.id)
|
||||
|
||||
setIsSyncing(null)
|
||||
} else if (response.ok) {
|
||||
// Legacy synchronous response (fallback)
|
||||
setSyncResults(prev => ({ ...prev, [connector.id]: result }))
|
||||
// Direct sync result - still show "sync started" message
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
message: "Check task notification panel for progress",
|
||||
isStarted: true
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to sync')
|
||||
throw new Error(result.error || 'Sync failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error)
|
||||
setSyncResults(prev => ({
|
||||
...prev,
|
||||
[connector.id]: {
|
||||
error: error instanceof Error ? error.message : 'Sync failed'
|
||||
}
|
||||
error: error instanceof Error ? error.message : 'Sync failed'
|
||||
}
|
||||
}))
|
||||
setIsSyncing(null)
|
||||
}
|
||||
|
|
@ -522,58 +427,30 @@ function ConnectorsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Sync Results and Progress */}
|
||||
{(syncResults[connector.id] || syncProgress[connector.id]) && (
|
||||
{/* Sync Results */}
|
||||
{syncResults[connector.id] && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
{syncProgress[connector.id] && (
|
||||
{syncResults[connector.id]?.isStarted && (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-600 mb-1">
|
||||
<RefreshCw className="inline h-3 w-3 mr-1 animate-spin" />
|
||||
Sync in Progress
|
||||
<RefreshCw className="inline h-3 w-3 mr-1" />
|
||||
Task initiated:
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>Status: {syncProgress[connector.id].status}</div>
|
||||
{syncProgress[connector.id].total > 0 && (
|
||||
<>
|
||||
<div>Progress: {syncProgress[connector.id].processed}/{syncProgress[connector.id].total} files</div>
|
||||
<div>Successful: {syncProgress[connector.id].successful}</div>
|
||||
{syncProgress[connector.id].failed > 0 && (
|
||||
<div className="text-red-500">
|
||||
Failed: {syncProgress[connector.id].failed}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="text-blue-600">
|
||||
{syncResults[connector.id]?.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResults[connector.id] && !syncProgress[connector.id] && (
|
||||
<>
|
||||
{syncResults[connector.id].error ? (
|
||||
<div className="text-sm text-red-500">
|
||||
<div className="font-medium">Sync Failed</div>
|
||||
<div>{syncResults[connector.id].error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-green-600 mb-1">
|
||||
<FileText className="inline h-3 w-3 mr-1" />
|
||||
Sync Completed
|
||||
</div>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<div>Processed: {syncResults[connector.id].processed || 0} files</div>
|
||||
<div>Added: {syncResults[connector.id].added || 0} documents</div>
|
||||
<div>Skipped: {syncResults[connector.id].skipped || 0} files</div>
|
||||
{syncResults[connector.id].errors > 0 && (
|
||||
<div className="text-red-500">
|
||||
Errors: {syncResults[connector.id].errors}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{syncResults[connector.id]?.error && (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4 inline mr-1" />
|
||||
Sync Failed
|
||||
</div>
|
||||
<div className="text-red-600">
|
||||
{syncResults[connector.id]?.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -87,3 +87,16 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||
import { Navigation } from "@/components/navigation";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { AuthProvider } from "@/contexts/auth-context";
|
||||
import { TaskProvider } from "@/contexts/task-context";
|
||||
import { UserNav } from "@/components/user-nav";
|
||||
import { LayoutWrapper } from "@/components/layout-wrapper";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -40,11 +42,14 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
<TaskProvider>
|
||||
<LayoutWrapper>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Bell, BellRing } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Navigation } from "@/components/navigation"
|
||||
import { ModeToggle } from "@/components/mode-toggle"
|
||||
import { UserNav } from "@/components/user-nav"
|
||||
import { TaskNotificationMenu } from "@/components/task-notification-menu"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
|
||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const { tasks, isMenuOpen, toggleMenu } = useTask()
|
||||
|
||||
// List of paths that should not show navigation
|
||||
const authPaths = ['/login', '/auth/callback']
|
||||
const isAuthPage = authPaths.includes(pathname)
|
||||
|
||||
// Calculate active tasks for the bell icon
|
||||
const activeTasks = tasks.filter(task =>
|
||||
task.status === 'pending' || task.status === 'running' || task.status === 'processing'
|
||||
)
|
||||
|
||||
if (isAuthPage) {
|
||||
// For auth pages, render without navigation
|
||||
return (
|
||||
|
|
@ -21,7 +32,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
// For all other pages, render with full navigation
|
||||
// For all other pages, render with full navigation and task menu
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background">
|
||||
|
|
@ -33,6 +44,27 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||
</div>
|
||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||
<nav className="flex items-center space-x-2">
|
||||
{/* Task Notification Bell */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleMenu}
|
||||
className="relative p-2"
|
||||
>
|
||||
{activeTasks.length > 0 ? (
|
||||
<BellRing className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{activeTasks.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-blue-500 text-white border-0"
|
||||
>
|
||||
{activeTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<UserNav />
|
||||
<ModeToggle />
|
||||
</nav>
|
||||
|
|
@ -42,15 +74,16 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||
<div className="hidden md:flex md:w-72 md:flex-col md:fixed md:top-14 md:bottom-0 md:left-0 z-[80] border-r border-border/40">
|
||||
<Navigation />
|
||||
</div>
|
||||
<main className="md:pl-72">
|
||||
<main className={`md:pl-72 ${isMenuOpen ? 'md:pr-80' : ''}`}>
|
||||
<div className="flex flex-col h-[calc(100vh-3.6rem)]">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
<div className="container py-6 lg:py-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<TaskNotificationMenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
309
frontend/src/components/task-notification-menu.tsx
Normal file
309
frontend/src/components/task-notification-menu.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, BellRing, 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'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TaskNotificationMenu() {
|
||||
const { tasks, isFetching, isMenuOpen, cancelTask } = useTask()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// 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 skipped = Math.max(0, processed - successful - failed) // Calculate skipped
|
||||
|
||||
if (total > 0) {
|
||||
return {
|
||||
basic: `${processed}/${total} files`,
|
||||
detailed: {
|
||||
total,
|
||||
processed,
|
||||
successful,
|
||||
failed,
|
||||
skipped,
|
||||
remaining: total - processed
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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">
|
||||
{activeTasks.length > 0 ? (
|
||||
<BellRing className="h-5 w-5 text-blue-500" />
|
||||
) : (
|
||||
<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)}
|
||||
</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-yellow-500 rounded-full"></div>
|
||||
<span className="text-yellow-600">
|
||||
{formatTaskProgress(task)?.detailed.skipped} skipped
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTaskProgress(task)?.detailed.remaining} 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)}
|
||||
</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.skipped} skipped
|
||||
</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>
|
||||
)
|
||||
}
|
||||
214
frontend/src/contexts/task-context.tsx
Normal file
214
frontend/src/contexts/task-context.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
|
||||
export interface Task {
|
||||
task_id: string
|
||||
status: 'pending' | 'running' | 'processing' | 'completed' | 'failed' | 'error'
|
||||
total_files?: number
|
||||
processed_files?: number
|
||||
successful_files?: number
|
||||
failed_files?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
result?: any
|
||||
error?: string
|
||||
files?: { [key: string]: any }
|
||||
}
|
||||
|
||||
interface TaskContextType {
|
||||
tasks: Task[]
|
||||
addTask: (taskId: string) => void
|
||||
removeTask: (taskId: string) => void
|
||||
refreshTasks: () => Promise<void>
|
||||
cancelTask: (taskId: string) => Promise<void>
|
||||
isPolling: boolean
|
||||
isFetching: boolean
|
||||
isMenuOpen: boolean
|
||||
toggleMenu: () => void
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextType | undefined>(undefined)
|
||||
|
||||
export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [trackedTaskIds, setTrackedTaskIds] = useState<Set<string>>(new Set())
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const response = await fetch('/api/tasks')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const newTasks = data.tasks || []
|
||||
|
||||
// Update tasks and check for status changes in the same state update
|
||||
setTasks(prevTasks => {
|
||||
// Check for newly completed tasks to show toasts
|
||||
if (prevTasks.length > 0) {
|
||||
newTasks.forEach((newTask: Task) => {
|
||||
const oldTask = prevTasks.find(t => t.task_id === newTask.task_id)
|
||||
if (oldTask && oldTask.status !== 'completed' && newTask.status === 'completed') {
|
||||
// Task just completed - show success toast
|
||||
toast.success("Task completed successfully!", {
|
||||
description: `Task ${newTask.task_id} has finished processing.`,
|
||||
action: {
|
||||
label: "View",
|
||||
onClick: () => console.log("View task", newTask.task_id),
|
||||
},
|
||||
})
|
||||
} else if (oldTask && oldTask.status !== 'failed' && oldTask.status !== 'error' && (newTask.status === 'failed' || newTask.status === 'error')) {
|
||||
// Task just failed - show error toast
|
||||
toast.error("Task failed", {
|
||||
description: `Task ${newTask.task_id} failed: ${newTask.error || 'Unknown error'}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return newTasks
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [isAuthenticated]) // Removed 'tasks' from dependencies to prevent infinite loop!
|
||||
|
||||
const addTask = useCallback((taskId: string) => {
|
||||
setTrackedTaskIds(prev => new Set(prev).add(taskId))
|
||||
|
||||
// Immediately start aggressive polling for the new task
|
||||
let pollAttempts = 0
|
||||
const maxPollAttempts = 30 // Poll for up to 30 seconds
|
||||
|
||||
const aggressivePoll = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const newTasks = data.tasks || []
|
||||
const foundTask = newTasks.find((task: Task) => task.task_id === taskId)
|
||||
|
||||
if (foundTask) {
|
||||
// Task found! Update the tasks state
|
||||
setTasks(prevTasks => {
|
||||
// Check if task is already in the list
|
||||
const exists = prevTasks.some(t => t.task_id === taskId)
|
||||
if (!exists) {
|
||||
return [...prevTasks, foundTask]
|
||||
}
|
||||
// Update existing task
|
||||
return prevTasks.map(t => t.task_id === taskId ? foundTask : t)
|
||||
})
|
||||
return // Stop polling, we found it
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Aggressive polling failed:', error)
|
||||
}
|
||||
|
||||
pollAttempts++
|
||||
if (pollAttempts < maxPollAttempts) {
|
||||
// Continue polling every 1 second for new tasks
|
||||
setTimeout(aggressivePoll, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Start aggressive polling after a short delay to allow backend to process
|
||||
setTimeout(aggressivePoll, 500)
|
||||
}, [])
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
await fetchTasks()
|
||||
}, [fetchTasks])
|
||||
|
||||
const removeTask = useCallback((taskId: string) => {
|
||||
setTrackedTaskIds(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(taskId)
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
const cancelTask = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Immediately refresh tasks to show the updated status
|
||||
await fetchTasks()
|
||||
toast.success("Task cancelled", {
|
||||
description: `Task ${taskId.substring(0, 8)}... has been cancelled`
|
||||
})
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to cancel task')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel task:', error)
|
||||
toast.error("Failed to cancel task", {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}, [fetchTasks])
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setIsMenuOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
// Periodic polling for task updates
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
setIsPolling(true)
|
||||
|
||||
// Initial fetch
|
||||
fetchTasks()
|
||||
|
||||
// Set up polling interval - every 3 seconds (more responsive for active tasks)
|
||||
const interval = setInterval(fetchTasks, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
setIsPolling(false)
|
||||
}
|
||||
}, [isAuthenticated, fetchTasks])
|
||||
|
||||
const value: TaskContextType = {
|
||||
tasks,
|
||||
addTask,
|
||||
removeTask,
|
||||
refreshTasks,
|
||||
cancelTask,
|
||||
isPolling,
|
||||
isFetching,
|
||||
isMenuOpen,
|
||||
toggleMenu,
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={value}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTask() {
|
||||
const context = useContext(TaskContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTask must be used within a TaskProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue