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",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|
@ -6409,6 +6410,16 @@
|
||||||
"is-arrayish": "^0.3.1"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Upload, FolderOpen, Loader2 } from "lucide-react"
|
import { Upload, FolderOpen, Loader2 } from "lucide-react"
|
||||||
import { ProtectedRoute } from "@/components/protected-route"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
|
import { useTask } from "@/contexts/task-context"
|
||||||
|
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
const [fileUploadLoading, setFileUploadLoading] = useState(false)
|
const [fileUploadLoading, setFileUploadLoading] = useState(false)
|
||||||
|
|
@ -14,6 +15,7 @@ function AdminPage() {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const [folderPath, setFolderPath] = useState("/app/documents/")
|
const [folderPath, setFolderPath] = useState("/app/documents/")
|
||||||
const [uploadStatus, setUploadStatus] = useState<string>("")
|
const [uploadStatus, setUploadStatus] = useState<string>("")
|
||||||
|
const { addTask } = useTask()
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.FormEvent) => {
|
const handleFileUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -68,7 +70,7 @@ function AdminPage() {
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (response.status === 201) {
|
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 taskId = result.task_id || result.id
|
||||||
const totalFiles = result.total_files || 0
|
const totalFiles = result.total_files || 0
|
||||||
|
|
||||||
|
|
@ -76,10 +78,12 @@ function AdminPage() {
|
||||||
throw new Error("No task ID received from server")
|
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
|
setUploadStatus(`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`)
|
||||||
await pollPathTaskStatus(taskId, totalFiles)
|
setFolderPath("")
|
||||||
|
setPathUploadLoading(false)
|
||||||
|
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
// Original flow: Direct response with results
|
// Original flow: Direct response with results
|
||||||
|
|
@ -87,72 +91,18 @@ function AdminPage() {
|
||||||
const total = result.results?.length || 0
|
const total = result.results?.length || 0
|
||||||
setUploadStatus(`Path processed successfully! ${successful}/${total} files indexed.`)
|
setUploadStatus(`Path processed successfully! ${successful}/${total} files indexed.`)
|
||||||
setFolderPath("")
|
setFolderPath("")
|
||||||
|
setPathUploadLoading(false)
|
||||||
} else {
|
} else {
|
||||||
setUploadStatus(`Error: ${result.error || "Path upload failed"}`)
|
setUploadStatus(`Error: ${result.error || "Path upload failed"}`)
|
||||||
|
setPathUploadLoading(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Path upload failed"}`)
|
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Path upload failed"}`)
|
||||||
} finally {
|
|
||||||
setPathUploadLoading(false)
|
setPathUploadLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollPathTaskStatus = async (taskId: string, totalFiles: number) => {
|
// Remove the old pollPathTaskStatus function since we're using centralized system
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default function AuthCallbackPage() {
|
||||||
const { refreshAuth } = useAuth()
|
const { refreshAuth } = useAuth()
|
||||||
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
|
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [purpose, setPurpose] = useState<string>("app_auth")
|
||||||
const hasProcessed = useRef(false)
|
const hasProcessed = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -32,10 +33,15 @@ export default function AuthCallbackPage() {
|
||||||
const storedConnectorType = localStorage.getItem('connecting_connector_type')
|
const storedConnectorType = localStorage.getItem('connecting_connector_type')
|
||||||
const authPurpose = localStorage.getItem('auth_purpose')
|
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
|
// Debug logging
|
||||||
console.log('Auth Callback Debug:', {
|
console.log('OAuth Callback Debug:', {
|
||||||
urlParams: { code: !!code, state: !!state, error: errorParam },
|
urlParams: { code: !!code, state: !!state, error: errorParam },
|
||||||
localStorage: { connectorId, storedConnectorType, authPurpose },
|
localStorage: { connectorId, storedConnectorType, authPurpose },
|
||||||
|
detectedPurpose,
|
||||||
fullUrl: window.location.href
|
fullUrl: window.location.href
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -47,7 +53,7 @@ export default function AuthCallbackPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state || !finalConnectorId) {
|
if (!code || !state || !finalConnectorId) {
|
||||||
console.error('Missing auth callback parameters:', {
|
console.error('Missing OAuth callback parameters:', {
|
||||||
code: !!code,
|
code: !!code,
|
||||||
state: !!state,
|
state: !!state,
|
||||||
finalConnectorId: !!finalConnectorId
|
finalConnectorId: !!finalConnectorId
|
||||||
|
|
@ -70,30 +76,44 @@ export default function AuthCallbackPage() {
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (response.ok && result.purpose === 'app_auth') {
|
if (response.ok) {
|
||||||
setStatus("success")
|
setStatus("success")
|
||||||
|
|
||||||
// Refresh auth context to pick up the new user
|
if (result.purpose === 'app_auth' || detectedPurpose === 'app_auth') {
|
||||||
await refreshAuth()
|
// App authentication - refresh auth context and redirect to home/original page
|
||||||
|
await refreshAuth()
|
||||||
// Clean up localStorage
|
|
||||||
localStorage.removeItem('connecting_connector_id')
|
// Get redirect URL from login page
|
||||||
localStorage.removeItem('connecting_connector_type')
|
const redirectTo = searchParams.get('redirect') || '/'
|
||||||
localStorage.removeItem('auth_purpose')
|
|
||||||
|
// Clean up localStorage
|
||||||
// Get redirect URL from login page
|
localStorage.removeItem('connecting_connector_id')
|
||||||
const redirectTo = searchParams.get('redirect') || '/'
|
localStorage.removeItem('connecting_connector_type')
|
||||||
|
localStorage.removeItem('auth_purpose')
|
||||||
// Redirect to the original page or home
|
|
||||||
setTimeout(() => {
|
// Redirect to the original page or home
|
||||||
router.push(redirectTo)
|
setTimeout(() => {
|
||||||
}, 2000)
|
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 {
|
} else {
|
||||||
throw new Error(result.error || 'Authentication failed')
|
throw new Error(result.error || 'Authentication failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth callback error:', err)
|
console.error('OAuth callback error:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||||
setStatus("error")
|
setStatus("error")
|
||||||
|
|
||||||
|
|
@ -107,6 +127,33 @@ export default function AuthCallbackPage() {
|
||||||
handleCallback()
|
handleCallback()
|
||||||
}, [searchParams, router, refreshAuth])
|
}, [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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|
@ -115,26 +162,24 @@ export default function AuthCallbackPage() {
|
||||||
{status === "processing" && (
|
{status === "processing" && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
Signing you in...
|
{getTitle()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "success" && (
|
{status === "success" && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
Welcome to GenDB!
|
{getTitle()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<>
|
<>
|
||||||
<XCircle className="h-5 w-5 text-red-500" />
|
<XCircle className="h-5 w-5 text-red-500" />
|
||||||
Sign In Failed
|
{getTitle()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{status === "processing" && "Please wait while we complete your sign in..."}
|
{getDescription()}
|
||||||
{status === "success" && "You will be redirected shortly."}
|
|
||||||
{status === "error" && "There was an issue signing you in."}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -144,12 +189,12 @@ export default function AuthCallbackPage() {
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push('/login')}
|
onClick={() => router.push(isAppAuth ? '/login' : '/connectors')}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Back to Login
|
{isAppAuth ? 'Back to Login' : 'Back to Connectors'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -157,7 +202,7 @@ export default function AuthCallbackPage() {
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-green-600">
|
||||||
Redirecting you to the app...
|
{isAppAuth ? 'Redirecting you to the app...' : 'Redirecting to connectors...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
|
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
|
||||||
import { ProtectedRoute } from "@/components/protected-route"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useTask } from "@/contexts/task-context"
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
|
|
@ -65,6 +67,8 @@ function ChatPage() {
|
||||||
const dragCounterRef = useRef(0)
|
const dragCounterRef = useRef(0)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { addTask } = useTask()
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
|
@ -120,7 +124,7 @@ function ChatPage() {
|
||||||
console.log("Upload result:", result)
|
console.log("Upload result:", result)
|
||||||
|
|
||||||
if (response.status === 201) {
|
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
|
const taskId = result.task_id || result.id
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
|
|
@ -128,17 +132,17 @@ function ChatPage() {
|
||||||
throw new Error("No task ID received from server")
|
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 = {
|
const pollingMessage: Message = {
|
||||||
role: "assistant",
|
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()
|
timestamp: new Date()
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev.slice(0, -1), pollingMessage])
|
setMessages(prev => [...prev.slice(0, -1), pollingMessage])
|
||||||
|
|
||||||
// Start polling the task status
|
|
||||||
await pollTaskStatus(taskId, file.name)
|
|
||||||
|
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
// Original flow: Direct response
|
// Original flow: Direct response
|
||||||
|
|
||||||
|
|
@ -175,101 +179,7 @@ function ChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollTaskStatus = async (taskId: string, filename: string) => {
|
// Remove the old pollTaskStatus function since we're using centralized system
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
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 { Label } from "@/components/ui/label"
|
||||||
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, FileText, Download, AlertCircle } from "lucide-react"
|
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, FileText, Download, AlertCircle } from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useTask } from "@/contexts/task-context"
|
||||||
import { ProtectedRoute } from "@/components/protected-route"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
|
|
||||||
interface Connector {
|
interface Connector {
|
||||||
|
|
@ -19,6 +20,7 @@ interface Connector {
|
||||||
status: "not_connected" | "connecting" | "connected" | "error"
|
status: "not_connected" | "connecting" | "connected" | "error"
|
||||||
type: string
|
type: string
|
||||||
connectionId?: string // Store the active connection ID for syncing
|
connectionId?: string // Store the active connection ID for syncing
|
||||||
|
access_token?: string // For connectors that use OAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectorStatus {
|
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() {
|
function ConnectorsPage() {
|
||||||
const { user, isAuthenticated } = useAuth()
|
const { user, isAuthenticated } = useAuth()
|
||||||
|
const { addTask, refreshTasks } = useTask()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [connectors, setConnectors] = useState<Connector[]>([
|
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 [isConnecting, setIsConnecting] = useState<string | null>(null)
|
const [isConnecting, setIsConnecting] = useState<string | null>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState<string | null>(null)
|
const [isSyncing, setIsSyncing] = useState<string | null>(null)
|
||||||
const [syncResults, setSyncResults] = useState<{ [key: string]: any }>({})
|
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
|
||||||
const [syncProgress, setSyncProgress] = useState<{ [key: string]: any }>({})
|
const [syncProgress, setSyncProgress] = useState<{[key: string]: number | null}>({})
|
||||||
const [maxFiles, setMaxFiles] = useState<number>(10)
|
const [maxFiles, setMaxFiles] = useState<number>(10)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
// Function definitions first
|
// Function definitions first
|
||||||
const checkConnectorStatuses = async () => {
|
const checkConnectorStatuses = async () => {
|
||||||
for (const connector of connectors) {
|
// Initialize connectors list
|
||||||
try {
|
setConnectors([
|
||||||
const response = await fetch(`/api/connectors/status/${connector.type}`)
|
{
|
||||||
|
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) {
|
if (response.ok) {
|
||||||
const status: ConnectorStatus = await response.json()
|
const data = await response.json()
|
||||||
const isConnected = status.authenticated
|
const connections = data.connections || []
|
||||||
|
const activeConnection = connections.find((conn: any) => conn.is_active)
|
||||||
// Find the first active connection to use for syncing
|
const isConnected = activeConnection !== undefined
|
||||||
const activeConnection = status.connections?.find(conn => conn.is_active)
|
|
||||||
|
|
||||||
setConnectors(prev => prev.map(c =>
|
setConnectors(prev => prev.map(c =>
|
||||||
c.id === connector.id
|
c.type === connectorType
|
||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
status: isConnected ? "connected" : "not_connected",
|
status: isConnected ? "connected" : "not_connected",
|
||||||
connectionId: activeConnection?.connection_id
|
connectionId: activeConnection?.connection_id
|
||||||
}
|
}
|
||||||
: c
|
: 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 refreshConnectorStatus = async (connector: Connector) => {
|
||||||
const connector = connectors.find(c => c.id === connectorId)
|
|
||||||
if (!connector) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/connectors/status/${connector.type}`)
|
const response = await fetch(`/api/connectors/${connector.type}/status`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const status: ConnectorStatus = await response.json()
|
const data = await response.json()
|
||||||
const isConnected = status.authenticated
|
const connections = data.connections || []
|
||||||
|
const activeConnection = connections.find((conn: any) => conn.is_active)
|
||||||
// Find the first active connection to use for syncing
|
const isConnected = activeConnection !== undefined
|
||||||
const activeConnection = status.connections?.find(conn => conn.is_active)
|
|
||||||
|
|
||||||
setConnectors(prev => prev.map(c =>
|
setConnectors(prev => prev.map(c =>
|
||||||
c.id === connectorId
|
c.id === connector.id
|
||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
status: isConnected ? "connected" : "not_connected",
|
status: isConnected ? "connected" : "not_connected",
|
||||||
connectionId: activeConnection?.connection_id
|
connectionId: activeConnection?.connection_id
|
||||||
}
|
}
|
||||||
: c
|
: c
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
// Frontend determines the correct redirect URI using its own origin
|
// Use the shared auth callback URL, not a separate connectors callback
|
||||||
const redirectUri = `${window.location.origin}/connectors/callback`
|
const redirectUri = `${window.location.origin}/auth/callback`
|
||||||
|
|
||||||
const response = await fetch('/api/auth/init', {
|
const response = await fetch('/api/auth/init', {
|
||||||
method: 'POST',
|
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) => {
|
const handleSync = async (connector: Connector) => {
|
||||||
setIsSyncing(connector.id)
|
|
||||||
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
|
||||||
setSyncProgress(prev => ({ ...prev, [connector.id]: null }))
|
|
||||||
|
|
||||||
if (!connector.connectionId) {
|
if (!connector.connectionId) {
|
||||||
console.error('No connection ID available for syncing')
|
console.error('No connection ID available for connector')
|
||||||
setSyncResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[connector.id]: {
|
|
||||||
error: 'No active connection found. Please reconnect and try again.'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setIsSyncing(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSyncing(connector.id)
|
||||||
|
setSyncProgress(prev => ({ ...prev, [connector.id]: null })) // Clear any existing progress
|
||||||
|
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/connectors/sync', {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
connection_id: connector.connectionId,
|
|
||||||
max_files: maxFiles
|
max_files: maxFiles
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (response.status === 201 && result.task_id) {
|
if (response.status === 201 && result.task_id) {
|
||||||
// Async sync started, begin polling for status
|
// Task-based sync, use centralized tracking
|
||||||
setSyncProgress(prev => ({
|
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,
|
...prev,
|
||||||
[connector.id]: {
|
[connector.id]: {
|
||||||
status: 'pending',
|
message: "Check task notification panel for progress",
|
||||||
processed: 0,
|
isStarted: true
|
||||||
total: 0,
|
|
||||||
successful: 0,
|
|
||||||
failed: 0
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
setIsSyncing(null)
|
||||||
// Start polling for task status
|
|
||||||
await pollTaskStatus(result.task_id, connector.id)
|
|
||||||
|
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
// Legacy synchronous response (fallback)
|
// Direct sync result - still show "sync started" message
|
||||||
setSyncResults(prev => ({ ...prev, [connector.id]: result }))
|
setSyncResults(prev => ({
|
||||||
|
...prev,
|
||||||
|
[connector.id]: {
|
||||||
|
message: "Check task notification panel for progress",
|
||||||
|
isStarted: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
setIsSyncing(null)
|
setIsSyncing(null)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Failed to sync')
|
throw new Error(result.error || 'Sync failed')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error)
|
console.error('Sync failed:', error)
|
||||||
setSyncResults(prev => ({
|
setSyncResults(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[connector.id]: {
|
[connector.id]: {
|
||||||
error: error instanceof Error ? error.message : 'Sync failed'
|
error: error instanceof Error ? error.message : 'Sync failed'
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
setIsSyncing(null)
|
setIsSyncing(null)
|
||||||
}
|
}
|
||||||
|
|
@ -522,58 +427,30 @@ function ConnectorsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync Results and Progress */}
|
{/* Sync Results */}
|
||||||
{(syncResults[connector.id] || syncProgress[connector.id]) && (
|
{syncResults[connector.id] && (
|
||||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
{syncProgress[connector.id] && (
|
{syncResults[connector.id]?.isStarted && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium text-blue-600 mb-1">
|
<div className="font-medium text-blue-600 mb-1">
|
||||||
<RefreshCw className="inline h-3 w-3 mr-1 animate-spin" />
|
<RefreshCw className="inline h-3 w-3 mr-1" />
|
||||||
Sync in Progress
|
Task initiated:
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-muted-foreground">
|
<div className="text-blue-600">
|
||||||
<div>Status: {syncProgress[connector.id].status}</div>
|
{syncResults[connector.id]?.message}
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{syncResults[connector.id]?.error && (
|
||||||
{syncResults[connector.id] && !syncProgress[connector.id] && (
|
<div className="text-sm">
|
||||||
<>
|
<div className="font-medium text-red-600 mb-1">
|
||||||
{syncResults[connector.id].error ? (
|
<XCircle className="h-4 w-4 inline mr-1" />
|
||||||
<div className="text-sm text-red-500">
|
Sync Failed
|
||||||
<div className="font-medium">Sync Failed</div>
|
</div>
|
||||||
<div>{syncResults[connector.id].error}</div>
|
<div className="text-red-600">
|
||||||
</div>
|
{syncResults[connector.id]?.error}
|
||||||
) : (
|
</div>
|
||||||
<div className="text-sm">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,16 @@
|
||||||
@apply bg-background text-foreground;
|
@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 { Navigation } from "@/components/navigation";
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
import { AuthProvider } from "@/contexts/auth-context";
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
import { TaskProvider } from "@/contexts/task-context";
|
||||||
import { UserNav } from "@/components/user-nav";
|
import { UserNav } from "@/components/user-nav";
|
||||||
import { LayoutWrapper } from "@/components/layout-wrapper";
|
import { LayoutWrapper } from "@/components/layout-wrapper";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -40,11 +42,14 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<LayoutWrapper>
|
<TaskProvider>
|
||||||
{children}
|
<LayoutWrapper>
|
||||||
</LayoutWrapper>
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
</TaskProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { usePathname } from "next/navigation"
|
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 { Navigation } from "@/components/navigation"
|
||||||
import { ModeToggle } from "@/components/mode-toggle"
|
import { ModeToggle } from "@/components/mode-toggle"
|
||||||
import { UserNav } from "@/components/user-nav"
|
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 }) {
|
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { tasks, isMenuOpen, toggleMenu } = useTask()
|
||||||
|
|
||||||
// List of paths that should not show navigation
|
// List of paths that should not show navigation
|
||||||
const authPaths = ['/login', '/auth/callback']
|
const authPaths = ['/login', '/auth/callback']
|
||||||
const isAuthPage = authPaths.includes(pathname)
|
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) {
|
if (isAuthPage) {
|
||||||
// For auth pages, render without navigation
|
// For auth pages, render without navigation
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="h-full relative">
|
<div className="h-full relative">
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background">
|
<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>
|
||||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
<nav className="flex items-center 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 />
|
<UserNav />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</nav>
|
</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">
|
<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 />
|
<Navigation />
|
||||||
</div>
|
</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 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">
|
<div className="container py-6 lg:py-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<TaskNotificationMenu />
|
||||||
</div>
|
</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