refactor: simplify connector management and update dashboard layout

- Removed the Connectors management page and integrated its functionality into a popup for better user experience.
- Updated the DashboardLayout to reflect changes in navigation, removing references to the Connectors page.
- Streamlined the breadcrumb component by eliminating unnecessary connector-related sections.
- Enhanced the ConnectorIndicator to facilitate easier access to connector management features.
- Improved overall UI consistency and accessibility across the dashboard components.
This commit is contained in:
Anish Sarkar 2026-01-01 21:41:31 +05:30
parent b909032e32
commit 3ae8fe3a7e
7 changed files with 65 additions and 999 deletions

View file

@ -1,857 +0,0 @@
"use client";
import { format } from "date-fns";
import { useAtomValue } from "jotai";
import {
Calendar as CalendarIcon,
Clock,
Edit,
Folder,
HardDrive,
Info,
Loader2,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
deleteConnectorMutationAtom,
indexConnectorMutationAtom,
updateConnectorMutationAtom,
} from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
export default function ConnectorsPage() {
const t = useTranslations("connectors");
const tCommon = useTranslations("common");
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return t("never");
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const today = new Date();
const { data: connectors = [], isLoading, error } = useAtomValue(connectorsAtom);
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(
null
);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
// Periodic indexing state
const [periodicDialogOpen, setPeriodicDialogOpen] = useState(false);
const [selectedConnectorForPeriodic, setSelectedConnectorForPeriodic] = useState<number | null>(
null
);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState<string>("1440");
const [customFrequency, setCustomFrequency] = useState<string>("");
const [isSavingPeriodic, setIsSavingPeriodic] = useState(false);
// Google Drive folder and file selection state
const [driveFolderDialogOpen, setDriveFolderDialogOpen] = useState(false);
const [selectedFolders, setSelectedFolders] = useState<Array<{ id: string; name: string }>>([]);
const [selectedFiles, setSelectedFiles] = useState<Array<{ id: string; name: string }>>([]);
useEffect(() => {
if (error) {
toast.error(t("failed_load"));
console.error("Error fetching connectors:", error);
}
}, [error, t]);
// Handle connector deletion
const handleDeleteConnector = async () => {
if (connectorToDelete === null) return;
try {
await deleteConnector({ id: connectorToDelete });
} catch (error) {
console.error("Error deleting connector:", error);
} finally {
setConnectorToDelete(null);
}
};
// Handle opening date picker for indexing
const handleOpenDatePicker = (connectorId: number) => {
// Check if this is a Google Drive connector
const connector = connectors.find((c) => c.id === connectorId);
if (connector?.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR) {
// Open folder selection dialog for Google Drive
handleOpenDriveFolderDialog(connectorId);
} else {
// Open date picker for other connectors
setSelectedConnectorForIndexing(connectorId);
setDatePickerOpen(true);
}
};
const handleOpenDriveFolderDialog = (connectorId: number) => {
setSelectedConnectorForIndexing(connectorId);
setDriveFolderDialogOpen(true);
};
// Handle Google Drive folder and file indexing
const handleIndexGoogleDrive = async () => {
if (selectedConnectorForIndexing === null || (selectedFolders.length === 0 && selectedFiles.length === 0)) {
toast.error("Please select at least one folder or file");
return;
}
setDriveFolderDialogOpen(false);
try {
setIndexingConnectorId(selectedConnectorForIndexing);
await indexConnector({
connector_id: selectedConnectorForIndexing,
body: {
folders: selectedFolders,
files: selectedFiles,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success(t("indexing_started"));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
setSelectedFolders([]);
setSelectedFiles([]);
}
};
// Handle connector indexing with dates
const handleIndexConnector = async () => {
if (selectedConnectorForIndexing === null) return;
setDatePickerOpen(false);
try {
setIndexingConnectorId(selectedConnectorForIndexing);
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector({
connector_id: selectedConnectorForIndexing,
queryParams: {
search_space_id: searchSpaceId,
start_date: startDateStr,
end_date: endDateStr,
},
});
toast.success(t("indexing_started"));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
setStartDate(undefined);
setEndDate(undefined);
}
};
// Handle indexing without date picker (for quick indexing)
const handleQuickIndexConnector = async (connectorId: number) => {
setIndexingConnectorId(connectorId);
try {
await indexConnector({
connector_id: connectorId,
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success(t("indexing_started"));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
} finally {
setIndexingConnectorId(null);
}
};
// Handle opening periodic indexing dialog
const handleOpenPeriodicDialog = (connectorId: number) => {
const connector = connectors.find((c) => c.id === connectorId);
if (!connector) return;
setSelectedConnectorForPeriodic(connectorId);
setPeriodicEnabled(connector.periodic_indexing_enabled);
if (connector.indexing_frequency_minutes) {
// Check if it's a preset value
const presetValues = ["15", "60", "360", "720", "1440", "10080"];
if (presetValues.includes(connector.indexing_frequency_minutes.toString())) {
setFrequencyMinutes(connector.indexing_frequency_minutes.toString());
setCustomFrequency("");
} else {
setFrequencyMinutes("custom");
setCustomFrequency(connector.indexing_frequency_minutes.toString());
}
} else {
setFrequencyMinutes("1440");
setCustomFrequency("");
}
setPeriodicDialogOpen(true);
};
// Handle saving periodic indexing configuration
const handleSavePeriodicIndexing = async () => {
if (selectedConnectorForPeriodic === null) return;
const connector = connectors.find((c) => c.id === selectedConnectorForPeriodic);
if (!connector) return;
setIsSavingPeriodic(true);
try {
// Determine the frequency value
let frequency: number | null = null;
if (periodicEnabled) {
if (frequencyMinutes === "custom") {
frequency = parseInt(customFrequency, 10);
if (isNaN(frequency) || frequency <= 0) {
toast.error("Please enter a valid frequency in minutes");
setIsSavingPeriodic(false);
return;
}
} else {
frequency = parseInt(frequencyMinutes, 10);
}
}
await updateConnector({
id: selectedConnectorForPeriodic,
data: {
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: frequency,
},
});
toast.success(
periodicEnabled
? "Periodic indexing enabled successfully"
: "Periodic indexing disabled successfully"
);
setPeriodicDialogOpen(false);
} catch (error) {
console.error("Error updating periodic indexing:", error);
toast.error(error instanceof Error ? error.message : "Failed to update periodic indexing");
} finally {
setIsSavingPeriodic(false);
setSelectedConnectorForPeriodic(null);
}
};
// Format frequency for display
const formatFrequency = (minutes: number): string => {
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
if (minutes < 10080) return `${Math.floor(minutes / 1440)}d`;
return `${Math.floor(minutes / 10080)}w`;
};
return (
<div className="container mx-auto py-8 px-4 max-w-6xl min-h-[calc(100vh-64px)]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-8 flex items-center justify-between gap-2"
>
<div>
<h1 className="text-xl md:text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-xs md:text-base text-muted-foreground mt-2">{t("subtitle")}</p>
</div>
<Button
className="h-8 text-xs px-3 md:h-10 md:text-sm md:px-4"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<Plus className="mr-2 h-3 w-3 md:h-4 md:w-4" />
{t("add_connector")}
</Button>
</motion.div>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-pulse text-center">
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
</div>
</div>
) : connectors.length === 0 ? (
<div className="text-center py-12">
<h3 className="text-lg font-medium mb-2">{t("no_connectors")}</h3>
<p className="text-muted-foreground mb-6">{t("no_connectors_desc")}</p>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
{t("add_first")}
</Button>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("last_indexed")}</TableHead>
<TableHead>{t("periodic")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((connector) => (
<TableRow key={connector.id}>
<TableCell className="font-medium">{connector.name}</TableCell>
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
<TableCell>
{connector.is_indexable
? formatDateTime(connector.last_indexed_at)
: t("not_indexable")}
</TableCell>
<TableCell>
{connector.is_indexable ? (
connector.periodic_indexing_enabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Clock className="h-4 w-4" />
<span className="text-sm font-medium">
{connector.indexing_frequency_minutes
? formatFrequency(connector.indexing_frequency_minutes)
: "Enabled"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Runs every {connector.indexing_frequency_minutes} minutes
{connector.next_scheduled_at && (
<>
<br />
Next: {formatDateTime(connector.next_scheduled_at)}
</>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-sm text-muted-foreground">Disabled</span>
)
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{connector.is_indexable && (
<div className="flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenDatePicker(connector.id)}
disabled={indexingConnectorId === connector.id}
>
{indexingConnectorId === connector.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR ? (
<Folder className="h-4 w-4" />
) : (
<CalendarIcon className="h-4 w-4" />
)}
<span className="sr-only">
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
? "Select folder to index"
: t("index_date_range")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
? "Select folder to index"
: t("index_date_range")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hide quick index button for Google Drive (requires folder selection) */}
{connector.connector_type !== EnumConnectorName.GOOGLE_DRIVE_CONNECTOR && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickIndexConnector(connector.id)}
disabled={indexingConnectorId === connector.id}
>
{indexingConnectorId === connector.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="sr-only">{t("quick_index")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("quick_index_auto")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{connector.is_indexable && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenPeriodicDialog(connector.id)}
>
<Clock className="h-4 w-4" />
<span className="sr-only">Configure Periodic Indexing</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Configure Periodic Indexing</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
)
}
>
<Edit className="h-4 w-4" />
<span className="sr-only">{tCommon("edit")}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive-foreground hover:bg-destructive/10"
onClick={() => setConnectorToDelete(connector.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">{tCommon("delete")}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_connector")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConnector}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Date Picker Dialog */}
<Dialog open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("select_date_range")}</DialogTitle>
<DialogDescription>{t("select_date_range_desc")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">{t("start_date")}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="start-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : t("pick_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={setStartDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">{t("end_date")}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="end-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : t("pick_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={endDate} onSelect={setEndDate} initialFocus />
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setStartDate(undefined);
setEndDate(undefined);
}}
>
{t("clear_dates")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
setStartDate(thirtyDaysAgo);
setEndDate(today);
}}
>
{t("last_30_days")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const yearAgo = new Date(today);
yearAgo.setFullYear(today.getFullYear() - 1);
setStartDate(yearAgo);
setEndDate(today);
}}
>
{t("last_year")}
</Button>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDatePickerOpen(false);
setSelectedConnectorForIndexing(null);
setStartDate(undefined);
setEndDate(undefined);
}}
>
{tCommon("cancel")}
</Button>
<Button onClick={handleIndexConnector}>{t("start_indexing")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Google Drive Folder Selection Dialog */}
<Dialog open={driveFolderDialogOpen} onOpenChange={setDriveFolderDialogOpen}>
<DialogContent className="w-auto max-w-full">
<DialogHeader>
<DialogTitle>Select Google Drive Folders & Files</DialogTitle>
<DialogDescription className="flex items-start gap-2 text-sm p-2 border mt-1 rounded ">
<Info className="h-4 w-4 shrink-0 text-blue-500" />
<span>
Select folders and/or individual files to index. For folders, only files <strong>directly in each folder</strong> will be
processedsubfolders must be selected separately.
</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 overflow-hidden w-full">
<div className="space-y-3 w-full overflow-hidden">
<Label>Browse Folders</Label>
{selectedConnectorForIndexing && (
<GoogleDriveFolderTree
connectorId={selectedConnectorForIndexing}
selectedFolders={selectedFolders}
onSelectFolders={(folders) => {
setSelectedFolders(folders);
}}
selectedFiles={selectedFiles}
onSelectFiles={(files) => {
setSelectedFiles(files);
}}
/>
)}
</div>
{(selectedFolders.length > 0 || selectedFiles.length > 0) && (
<div className="p-3 bg-muted rounded-lg text-sm space-y-2">
{selectedFolders.length > 0 && (
<div>
<p className="font-medium mb-1">
Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}:
</p>
<div className="max-h-24 overflow-y-auto">
{selectedFolders.map((folder) => (
<p key={folder.id} className="text-sm text-muted-foreground truncate" title={folder.name}>
📁 {folder.name}
</p>
))}
</div>
</div>
)}
{selectedFiles.length > 0 && (
<div>
<p className="font-medium mb-1">
Selected {selectedFiles.length} file{selectedFiles.length > 1 ? "s" : ""}:
</p>
<div className="max-h-24 overflow-y-auto">
{selectedFiles.map((file) => (
<p key={file.id} className="text-sm text-muted-foreground truncate" title={file.name}>
📄 {file.name}
</p>
))}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDriveFolderDialogOpen(false);
setSelectedConnectorForIndexing(null);
setSelectedFolders([]);
setSelectedFiles([]);
}}
>
{tCommon("cancel")}
</Button>
<Button onClick={handleIndexGoogleDrive} disabled={selectedFolders.length === 0 && selectedFiles.length === 0}>
{t("start_indexing")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Periodic Indexing Configuration Dialog */}
<Dialog open={periodicDialogOpen} onOpenChange={setPeriodicDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Configure Periodic Indexing</DialogTitle>
<DialogDescription>
Set up automatic indexing at regular intervals for this connector.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<Label htmlFor="periodic-enabled" className="text-base">
Enable Periodic Indexing
</Label>
<p className="text-sm text-muted-foreground">
Automatically index this connector at regular intervals
</p>
</div>
<Switch
id="periodic-enabled"
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
/>
</div>
{periodicEnabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="frequency">Indexing Frequency</Label>
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes}>
<SelectTrigger id="frequency">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">Every 15 minutes</SelectItem>
<SelectItem value="60">Every hour</SelectItem>
<SelectItem value="360">Every 6 hours</SelectItem>
<SelectItem value="720">Every 12 hours</SelectItem>
<SelectItem value="1440">Daily (24 hours)</SelectItem>
<SelectItem value="10080">Weekly (7 days)</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{frequencyMinutes === "custom" && (
<div className="space-y-2">
<Label htmlFor="custom-frequency">Custom Frequency (minutes)</Label>
<Input
id="custom-frequency"
type="number"
min="1"
placeholder="Enter minutes"
value={customFrequency}
onChange={(e) => setCustomFrequency(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Enter the number of minutes between each indexing run
</p>
</div>
)}
<div className="rounded-lg bg-muted p-3 text-sm">
<p className="font-medium mb-1">Preview:</p>
<p className="text-muted-foreground">
{frequencyMinutes === "custom" && customFrequency
? `Will run every ${customFrequency} minutes`
: frequencyMinutes === "15"
? "Will run every 15 minutes"
: frequencyMinutes === "60"
? "Will run every hour"
: frequencyMinutes === "360"
? "Will run every 6 hours"
: frequencyMinutes === "720"
? "Will run every 12 hours"
: frequencyMinutes === "1440"
? "Will run daily (every 24 hours)"
: frequencyMinutes === "10080"
? "Will run weekly (every 7 days)"
: "Select a frequency above"}
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setPeriodicDialogOpen(false);
setSelectedConnectorForPeriodic(null);
}}
>
Cancel
</Button>
<Button onClick={handleSavePeriodicIndexing} disabled={isSavingPeriodic}>
{isSavingPeriodic && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Configuration
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -26,28 +26,24 @@ export default function DashboardLayout({
}, },
]; ];
const customNavMain = [ const customNavMain = [
{ {
title: "Chat", title: "Chat",
url: `/dashboard/${search_space_id}/new-chat`, url: `/dashboard/${search_space_id}/new-chat`,
icon: "SquareTerminal", icon: "SquareTerminal",
items: [], items: [],
}, },
{ {
title: "Sources", title: "Sources",
url: "#", url: "#",
icon: "Database", icon: "Database",
items: [ items: [
{ {
title: "Manage Documents", title: "Manage Documents",
url: `/dashboard/${search_space_id}/documents`, url: `/dashboard/${search_space_id}/documents`,
}, },
{ ],
title: "Manage Connectors", },
url: `/dashboard/${search_space_id}/connectors`,
},
],
},
{ {
title: "Logs", title: "Logs",
url: `/dashboard/${search_space_id}/logs`, url: `/dashboard/${search_space_id}/logs`,

View file

@ -1,7 +1,6 @@
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react"; import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, ArrowUpIcon, Loader2, Plus, Plug2, SquareIcon } from "lucide-react"; import { AlertCircle, ArrowUpIcon, Loader2, Plus, Plug2, SquareIcon } from "lucide-react";
import Link from "next/link";
import type { FC } from "react"; import type { FC } from "react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
@ -148,14 +147,15 @@ const ConnectorIndicator: FC = () => {
</> </>
)} )}
<div className="pt-1 border-t border-border/50"> <div className="pt-1 border-t border-border/50">
<Link <button
href={`/dashboard/${searchSpaceId}/connectors/add`} type="button"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {/* Connector popup should be opened via the connector indicator button */}}
> >
<Plus className="size-3" /> <Plus className="size-3" />
Add more sources Add more sources
<ChevronRightIcon className="size-3" /> <ChevronRightIcon className="size-3" />
</Link> </button>
</div> </div>
</div> </div>
) : ( ) : (
@ -164,13 +164,14 @@ const ConnectorIndicator: FC = () => {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Add documents or connect data sources to enhance search results. Add documents or connect data sources to enhance search results.
</p> </p>
<Link <button
href={`/dashboard/${searchSpaceId}/connectors/add`} type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1" className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
onClick={() => {/* Connector popup should be opened via the connector indicator button */}}
> >
<Plus className="size-3" /> <Plus className="size-3" />
Add Connector Add Connector
</Link> </button>
</div> </div>
)} )}
</PopoverContent> </PopoverContent>

View file

@ -688,29 +688,7 @@ export const useConnectorDialog = () => {
const handleStartEdit = useCallback((connector: SearchSourceConnector) => { const handleStartEdit = useCallback((connector: SearchSourceConnector) => {
if (!searchSpaceId) return; if (!searchSpaceId) return;
// Check if this is an OAuth connector // All connector types should be handled in the popup edit view
const isOAuthConnector = OAUTH_CONNECTORS.some(
(oauthConnector) => oauthConnector.connectorType === connector.connector_type
);
// Check if this is webcrawler, Tavily API, SearxNG, Linkup, Baidu, Linear, Elasticsearch, Slack, Discord, or Notion (can be managed in popup)
const isWebcrawler = connector.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR;
const isTavilyApi = connector.connector_type === EnumConnectorName.TAVILY_API;
const isSearxng = connector.connector_type === EnumConnectorName.SEARXNG_API;
const isLinkup = connector.connector_type === EnumConnectorName.LINKUP_API;
const isBaidu = connector.connector_type === EnumConnectorName.BAIDU_SEARCH_API;
const isLinear = connector.connector_type === EnumConnectorName.LINEAR_CONNECTOR;
const isElasticsearch = connector.connector_type === EnumConnectorName.ELASTICSEARCH_CONNECTOR;
const isSlack = connector.connector_type === EnumConnectorName.SLACK_CONNECTOR;
const isDiscord = connector.connector_type === EnumConnectorName.DISCORD_CONNECTOR;
const isNotion = connector.connector_type === EnumConnectorName.NOTION_CONNECTOR;
// If not OAuth, not webcrawler, not Tavily API, not SearxNG, not Linkup, not Baidu, not Linear, not Elasticsearch, not Slack, not Discord, and not Notion, redirect to old connector edit page
if (!isOAuthConnector && !isWebcrawler && !isTavilyApi && !isSearxng && !isLinkup && !isBaidu && !isLinear && !isElasticsearch && !isSlack && !isDiscord && !isNotion) {
router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`);
return;
}
// Validate connector data // Validate connector data
const connectorValidation = searchSourceConnector.safeParse(connector); const connectorValidation = searchSourceConnector.safeParse(connector);
if (!connectorValidation.success) { if (!connectorValidation.success) {
@ -733,7 +711,7 @@ export const useConnectorDialog = () => {
url.searchParams.set("view", "edit"); url.searchParams.set("view", "edit");
url.searchParams.set("connectorId", connector.id.toString()); url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString()); window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId, router]); }, [searchSpaceId]);
// Handle saving connector changes // Handle saving connector changes
const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => { const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => {

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import type { FC } from "react";
import { type FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { OAUTH_CONNECTORS, CRAWLERS, OTHER_CONNECTORS } from "../constants/connector-constants"; import { OAUTH_CONNECTORS, CRAWLERS, OTHER_CONNECTORS } from "../constants/connector-constants";
@ -39,8 +38,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onCreateYouTubeCrawler, onCreateYouTubeCrawler,
onManage, onManage,
}) => { }) => {
const router = useRouter();
// Helper to find active task for a connector // Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => { const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
if (!logsSummary?.active_tasks) return undefined; if (!logsSummary?.active_tasks) return undefined;
@ -148,9 +145,11 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
: isWebcrawler && onCreateWebcrawler : isWebcrawler && onCreateWebcrawler
? onCreateWebcrawler ? onCreateWebcrawler
: crawler.connectorType && onConnectNonOAuth : crawler.connectorType && onConnectNonOAuth
? () => onConnectNonOAuth(crawler.connectorType!) ? () => {
: crawler.connectorType if (crawler.connectorType) {
? () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${crawler.id}`) onConnectNonOAuth(crawler.connectorType);
}
}
: () => {}; // Fallback for non-connector crawlers : () => {}; // Fallback for non-connector crawlers
return ( return (
@ -186,7 +185,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredOther.map((connector) => { {filteredOther.map((connector) => {
// Special handling for connectors that can be created in popup // Special handling for connectors that can be created in popup
const isWebcrawler = connector.id === "webcrawler-connector";
const isTavily = connector.id === "tavily-api"; const isTavily = connector.id === "tavily-api";
const isSearxng = connector.id === "searxng"; const isSearxng = connector.id === "searxng";
const isLinkup = connector.id === "linkup-api"; const isLinkup = connector.id === "linkup-api";
@ -216,11 +214,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id); const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined; const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
const handleConnect = isWebcrawler && onCreateWebcrawler const handleConnect = (isTavily || isSearxng || isLinkup || isBaidu || isLinear || isElasticsearch || isSlack || isDiscord || isNotion || isConfluence || isBookStack || isGithub || isJira || isClickUp || isLuma || isCircleback) && onConnectNonOAuth
? onCreateWebcrawler
: (isTavily || isSearxng || isLinkup || isBaidu || isLinear || isElasticsearch || isSlack || isDiscord || isNotion || isConfluence || isBookStack || isGithub || isJira || isClickUp || isLuma || isCircleback) && onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType) ? () => onConnectNonOAuth(connector.connectorType)
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`); : () => {}; // Fallback - connector popup should handle all connector types
return ( return (
<ConnectorCard <ConnectorCard

View file

@ -97,7 +97,6 @@ export function DashboardBreadcrumb() {
const sectionLabels: Record<string, string> = { const sectionLabels: Record<string, string> = {
"new-chat": t("chat") || "Chat", "new-chat": t("chat") || "Chat",
documents: t("documents"), documents: t("documents"),
connectors: t("connectors"),
logs: t("logs"), logs: t("logs"),
settings: t("settings"), settings: t("settings"),
editor: t("editor"), editor: t("editor"),
@ -156,53 +155,6 @@ export function DashboardBreadcrumb() {
return breadcrumbs; return breadcrumbs;
} }
// Handle connector sub-sections
if (section === "connectors") {
// Handle specific connector types
if (subSection === "add" && segments[4]) {
const connectorType = segments[4];
const connectorLabels: Record<string, string> = {
"github-connector": "GitHub",
"jira-connector": "Jira",
"confluence-connector": "Confluence",
"bookstack-connector": "BookStack",
"discord-connector": "Discord",
"linear-connector": "Linear",
"clickup-connector": "ClickUp",
"slack-connector": "Slack",
"notion-connector": "Notion",
"tavily-api": "Tavily API",
"linkup-api": "LinkUp API",
"luma-connector": "Luma",
"elasticsearch-connector": "Elasticsearch",
};
const connectorLabel = connectorLabels[connectorType] || connectorType;
breadcrumbs.push({
label: "Connectors",
href: `/dashboard/${segments[1]}/connectors`,
});
breadcrumbs.push({
label: "Add Connector",
href: `/dashboard/${segments[1]}/connectors/add`,
});
breadcrumbs.push({ label: connectorLabel });
return breadcrumbs;
}
const connectorLabels: Record<string, string> = {
add: t("add_connector"),
manage: t("manage_connectors"),
};
const connectorLabel = connectorLabels[subSection] || subSection;
breadcrumbs.push({
label: t("connectors"),
href: `/dashboard/${segments[1]}/connectors`,
});
breadcrumbs.push({ label: connectorLabel });
return breadcrumbs;
}
// Handle other sub-sections // Handle other sub-sections
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1); let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
@ -210,8 +162,6 @@ export function DashboardBreadcrumb() {
upload: t("upload_documents"), upload: t("upload_documents"),
youtube: t("add_youtube"), youtube: t("add_youtube"),
webpage: t("add_webpages"), webpage: t("add_webpages"),
add: t("add_connector"),
edit: t("edit_connector"),
manage: t("manage"), manage: t("manage"),
}; };

View file

@ -16,6 +16,7 @@ import {
} from "@/components/editConnector/types"; } from "@/components/editConnector/types";
import type { EnumConnectorName } from "@/contracts/enums/connector"; import type { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors"; import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
import type { UpdateConnectorResponse } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
const normalizeListInput = (value: unknown): string[] => { const normalizeListInput = (value: unknown): string[] => {
@ -57,7 +58,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
// State managed by the hook // State managed by the hook
const [connector, setConnector] = useState<SearchSourceConnector | null>(null); const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [originalConfig, setOriginalConfig] = useState<Record<string, any> | null>(null); const [originalConfig, setOriginalConfig] = useState<Record<string, unknown> | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]); const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]);
const [originalPat, setOriginalPat] = useState<string>(""); const [originalPat, setOriginalPat] = useState<string>("");
@ -161,18 +162,18 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
} }
} else { } else {
toast.error("Connector not found."); toast.error("Connector not found.");
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}`);
} }
} }
}, [ }, [
connectorId, connectorId,
connectors, connectors,
connectorsLoading, connectorsLoading,
router, router,
searchSpaceId, searchSpaceId,
connector, connector, editForm.reset, patForm.reset
editForm, // Note: editForm and patForm are intentionally excluded from dependencies
patForm, // to prevent infinite loops. They are stable form objects from react-hook-form.
]); ]);
// Handlers managed by the hook // Handlers managed by the hook
@ -219,7 +220,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
setIsSaving(true); setIsSaving(true);
const updatePayload: Partial<SearchSourceConnector> = {}; const updatePayload: Partial<SearchSourceConnector> = {};
let configChanged = false; let configChanged = false;
let newConfig: Record<string, any> | null = null; let newConfig: Record<string, unknown> | null = null;
if (formData.name !== connector.name) { if (formData.name !== connector.name) {
updatePayload.name = formData.name; updatePayload.name = formData.name;
@ -296,12 +297,14 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
return; return;
} }
const candidateConfig: Record<string, any> = { SEARXNG_HOST: host }; const candidateConfig: Record<string, unknown> = { SEARXNG_HOST: host };
let hasChanges = host !== (originalConfig.SEARXNG_HOST || "").trim(); const originalHost = typeof originalConfig.SEARXNG_HOST === "string" ? originalConfig.SEARXNG_HOST : "";
let hasChanges = host !== originalHost.trim();
const apiKey = (formData.SEARXNG_API_KEY || "").trim(); const apiKey = (formData.SEARXNG_API_KEY || "").trim();
const originalApiKey = (originalConfig.SEARXNG_API_KEY || "").trim(); const originalApiKey = typeof originalConfig.SEARXNG_API_KEY === "string" ? originalConfig.SEARXNG_API_KEY : "";
if (apiKey !== originalApiKey) { const originalApiKeyTrimmed = originalApiKey.trim();
if (apiKey !== originalApiKeyTrimmed) {
candidateConfig.SEARXNG_API_KEY = apiKey || null; candidateConfig.SEARXNG_API_KEY = apiKey || null;
hasChanges = true; hasChanges = true;
} }
@ -321,8 +324,9 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
} }
const language = (formData.SEARXNG_LANGUAGE || "").trim(); const language = (formData.SEARXNG_LANGUAGE || "").trim();
const originalLanguage = (originalConfig.SEARXNG_LANGUAGE || "").trim(); const originalLanguage = typeof originalConfig.SEARXNG_LANGUAGE === "string" ? originalConfig.SEARXNG_LANGUAGE : "";
if (language !== originalLanguage) { const originalLanguageTrimmed = originalLanguage.trim();
if (language !== originalLanguageTrimmed) {
candidateConfig.SEARXNG_LANGUAGE = language || null; candidateConfig.SEARXNG_LANGUAGE = language || null;
hasChanges = true; hasChanges = true;
} }
@ -490,7 +494,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
) { ) {
newConfig = {}; newConfig = {};
if (formData.FIRECRAWL_API_KEY && formData.FIRECRAWL_API_KEY.trim()) { if (formData.FIRECRAWL_API_KEY?.trim()) {
if (!formData.FIRECRAWL_API_KEY.startsWith("fc-")) { if (!formData.FIRECRAWL_API_KEY.startsWith("fc-")) {
toast.warning( toast.warning(
"Firecrawl API keys typically start with 'fc-'. Please verify your key." "Firecrawl API keys typically start with 'fc-'. Please verify your key."
@ -504,7 +508,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
} }
if (formData.INITIAL_URLS !== undefined) { if (formData.INITIAL_URLS !== undefined) {
if (formData.INITIAL_URLS && formData.INITIAL_URLS.trim()) { if (formData.INITIAL_URLS?.trim()) {
newConfig.INITIAL_URLS = formData.INITIAL_URLS.trim(); newConfig.INITIAL_URLS = formData.INITIAL_URLS.trim();
} else if (originalConfig.INITIAL_URLS) { } else if (originalConfig.INITIAL_URLS) {
toast.info("URLs removed from crawler configuration."); toast.info("URLs removed from crawler configuration.");
@ -530,21 +534,19 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
} }
try { try {
await updateConnector({ const updatedConnector = await updateConnector({
id: connectorId, id: connectorId,
data: { data: {
...updatePayload, ...updatePayload,
connector_type: connector.connector_type as EnumConnectorName, connector_type: connector.connector_type as EnumConnectorName,
}, },
}); }) as UpdateConnectorResponse;
toast.success("Connector updated!"); toast.success("Connector updated!");
const newlySavedConfig = updatePayload.config || originalConfig; // Use the response from the API which has the full merged config
const newlySavedConfig = updatedConnector.config || originalConfig;
setOriginalConfig(newlySavedConfig); setOriginalConfig(newlySavedConfig);
if (updatePayload.name) { // Update connector state with the full updated connector from the API
setConnector((prev) => setConnector(updatedConnector);
prev ? { ...prev, name: updatePayload.name!, config: newlySavedConfig } : null
);
}
if (configChanged) { if (configChanged) {
if (connector.connector_type === "GITHUB_CONNECTOR") { if (connector.connector_type === "GITHUB_CONNECTOR") {
const savedGitHubConfig = newlySavedConfig as { const savedGitHubConfig = newlySavedConfig as {