diff --git a/frontend/components/ui/sonner.tsx b/frontend/components/ui/sonner.tsx new file mode 100644 index 00000000..452f4d9f --- /dev/null +++ b/frontend/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c53a7cc4..f016b6ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 69201d67..0239b079 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index b4255159..d3847701 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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(null) const [folderPath, setFolderPath] = useState("/app/documents/") const [uploadStatus, setUploadStatus] = useState("") + 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 => { - 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 (
diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index 22d3c25f..df40521a 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -13,6 +13,7 @@ export default function AuthCallbackPage() { const { refreshAuth } = useAuth() const [status, setStatus] = useState<"processing" | "success" | "error">("processing") const [error, setError] = useState(null) + const [purpose, setPurpose] = useState("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 (
@@ -115,26 +162,24 @@ export default function AuthCallbackPage() { {status === "processing" && ( <> - Signing you in... + {getTitle()} )} {status === "success" && ( <> - Welcome to GenDB! + {getTitle()} )} {status === "error" && ( <> - Sign In Failed + {getTitle()} )} - {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()} @@ -144,12 +189,12 @@ export default function AuthCallbackPage() {

{error}

)} @@ -157,7 +202,7 @@ export default function AuthCallbackPage() {

- Redirecting you to the app... + {isAppAuth ? 'Redirecting you to the app...' : 'Redirecting to connectors...'}

diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 3e2153df..f1d004ec 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -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(null) const inputRef = useRef(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 => { - 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() diff --git a/frontend/src/app/connectors/callback/page.tsx b/frontend/src/app/connectors/callback/page.tsx deleted file mode 100644 index f0b5cf1a..00000000 --- a/frontend/src/app/connectors/callback/page.tsx +++ /dev/null @@ -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(null) - const [connectorType, setConnectorType] = useState(null) - const [isAppAuth, setIsAppAuth] = useState(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 ( -
- - - - {status === "processing" && ( - <> - - {getTitle()} - - )} - {status === "success" && ( - <> - - {getTitle()} - - )} - {status === "error" && ( - <> - - {getTitle()} - - )} - - - {getDescription()} - - - - {status === "error" && ( -
-
-

{error}

-
- -
- )} - {status === "success" && ( -
-
-

- {isAppAuth ? 'Redirecting to home...' : 'Redirecting to connectors...'} -

-
-
- )} -
-
-
- ) -} \ No newline at end of file diff --git a/frontend/src/app/connectors/page.tsx b/frontend/src/app/connectors/page.tsx index a9a996e4..2aef12bc 100644 --- a/frontend/src/app/connectors/page.tsx +++ b/frontend/src/app/connectors/page.tsx @@ -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([ - { - id: "google_drive", - name: "Google Drive", - description: "Connect your Google Drive to automatically sync documents", - icon:
G
, - 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:
D
, - // status: "not_connected", - // type: "dropbox" - // } - ]) + const [connectors, setConnectors] = useState([]) const [isConnecting, setIsConnecting] = useState(null) const [isSyncing, setIsSyncing] = useState(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(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:
G
, + 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 => { - 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() { )} - {/* Sync Results and Progress */} - {(syncResults[connector.id] || syncProgress[connector.id]) && ( + {/* Sync Results */} + {syncResults[connector.id] && (
- {syncProgress[connector.id] && ( + {syncResults[connector.id]?.isStarted && (
- - Sync in Progress + + Task initiated:
-
-
Status: {syncProgress[connector.id].status}
- {syncProgress[connector.id].total > 0 && ( - <> -
Progress: {syncProgress[connector.id].processed}/{syncProgress[connector.id].total} files
-
Successful: {syncProgress[connector.id].successful}
- {syncProgress[connector.id].failed > 0 && ( -
- Failed: {syncProgress[connector.id].failed} -
- )} - - )} +
+ {syncResults[connector.id]?.message}
)} - - {syncResults[connector.id] && !syncProgress[connector.id] && ( - <> - {syncResults[connector.id].error ? ( -
-
Sync Failed
-
{syncResults[connector.id].error}
-
- ) : ( -
-
- - Sync Completed -
-
-
Processed: {syncResults[connector.id].processed || 0} files
-
Added: {syncResults[connector.id].added || 0} documents
-
Skipped: {syncResults[connector.id].skipped || 0} files
- {syncResults[connector.id].errors > 0 && ( -
- Errors: {syncResults[connector.id].errors} -
- )} -
-
- )} - + {syncResults[connector.id]?.error && ( +
+
+ + Sync Failed +
+
+ {syncResults[connector.id]?.error} +
+
)}
)} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d66b1e9e..3b7a3c7a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 */ + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 03412d7b..32f3c91a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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 > - - {children} - + + + {children} + + + ); diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index b0c89e9a..3164db34 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -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 (
@@ -33,6 +44,27 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
@@ -42,15 +74,16 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
-
+
-
+
{children}
+
) } \ No newline at end of file diff --git a/frontend/src/components/task-notification-menu.tsx b/frontend/src/components/task-notification-menu.tsx new file mode 100644 index 00000000..5e7797b1 --- /dev/null +++ b/frontend/src/components/task-notification-menu.tsx @@ -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 + case 'failed': + case 'error': + return + case 'pending': + return + case 'running': + case 'processing': + return + default: + return + } + } + + const getStatusBadge = (status: Task['status']) => { + switch (status) { + case 'completed': + return Completed + case 'failed': + case 'error': + return Failed + case 'pending': + return Pending + case 'running': + case 'processing': + return Processing + default: + return Unknown + } + } + + 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 ( +
+
+ {/* Header */} +
+
+
+ {activeTasks.length > 0 ? ( + + ) : ( + + )} +

Tasks

+ {isFetching && ( + + )} +
+ {activeTasks.length > 0 && ( + + {activeTasks.length} + + )} +
+
+ + {/* Content */} +
+ {/* Active Tasks */} + {activeTasks.length > 0 && ( +
+

Active Tasks

+ {activeTasks.map((task) => ( + + +
+ + {getTaskIcon(task.status)} + Task {task.task_id.substring(0, 8)}... + +
+ + Started {formatRelativeTime(task.created_at)} + +
+ {formatTaskProgress(task) && ( + +
+
+ Progress: {formatTaskProgress(task)?.basic} +
+ {formatTaskProgress(task)?.detailed && ( +
+
+
+ + {formatTaskProgress(task)?.detailed.successful} success + +
+
+
+ + {formatTaskProgress(task)?.detailed.failed} failed + +
+
+
+ + {formatTaskProgress(task)?.detailed.skipped} skipped + +
+
+
+ + {formatTaskProgress(task)?.detailed.remaining} pending + +
+
+ )} +
+ {/* Cancel button in bottom right */} + {(task.status === 'pending' || task.status === 'running' || task.status === 'processing') && ( +
+ +
+ )} +
+ )} + {/* Cancel button for tasks without progress */} + {!formatTaskProgress(task) && (task.status === 'pending' || task.status === 'running' || task.status === 'processing') && ( + +
+ +
+
+ )} +
+ ))} +
+ )} + + {/* Recent Tasks */} + {recentTasks.length > 0 && ( +
+
+

Recent Tasks

+ +
+ + {isExpanded && ( +
+ {recentTasks.map((task) => ( +
+ {getTaskIcon(task.status)} +
+
+ Task {task.task_id.substring(0, 8)}... +
+
+ {formatRelativeTime(task.updated_at)} +
+ {/* Show final results for completed tasks */} + {task.status === 'completed' && formatTaskProgress(task)?.detailed && ( +
+ {formatTaskProgress(task)?.detailed.successful} success, {' '} + {formatTaskProgress(task)?.detailed.failed} failed, {' '} + {formatTaskProgress(task)?.detailed.skipped} skipped +
+ )} + {task.status === 'failed' && task.error && ( +
+ {task.error} +
+ )} +
+ {getStatusBadge(task.status)} +
+ ))} +
+ )} +
+ )} + + {/* Empty State */} + {activeTasks.length === 0 && recentTasks.length === 0 && ( +
+ +

No tasks yet

+

+ Task notifications will appear here when you upload files or sync connectors. +

+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/contexts/task-context.tsx b/frontend/src/contexts/task-context.tsx new file mode 100644 index 00000000..56a7aade --- /dev/null +++ b/frontend/src/contexts/task-context.tsx @@ -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 + cancelTask: (taskId: string) => Promise + isPolling: boolean + isFetching: boolean + isMenuOpen: boolean + toggleMenu: () => void +} + +const TaskContext = createContext(undefined) + +export function TaskProvider({ children }: { children: React.ReactNode }) { + const [tasks, setTasks] = useState([]) + const [isPolling, setIsPolling] = useState(false) + const [isFetching, setIsFetching] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [trackedTaskIds, setTrackedTaskIds] = useState>(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 ( + + {children} + + ) +} + +export function useTask() { + const context = useContext(TaskContext) + if (context === undefined) { + throw new Error('useTask must be used within a TaskProvider') + } + return context +} \ No newline at end of file