Merge pull request #669 from CREDO23/sur-66-feature-support-multiple-connectors-of-the-same-type-per

[Feature] Support multiple connectors of the same type per search space
This commit is contained in:
Rohan Verma 2026-01-07 12:38:37 -08:00 committed by GitHub
commit b53ea565db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1420 additions and 362 deletions

View file

@ -19,9 +19,11 @@ import { ConnectorDialogHeader } from "./connector-popup/components/connector-di
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
@ -60,6 +62,7 @@ export const ConnectorIndicator: FC = () => {
periodicEnabled,
frequencyMinutes,
allConnectors,
viewingAccountsType,
setSearchQuery,
setStartDate,
setEndDate,
@ -81,6 +84,8 @@ export const ConnectorIndicator: FC = () => {
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
@ -194,6 +199,25 @@ export const ConnectorIndicator: FC = () => {
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
@ -289,6 +313,7 @@ export const ConnectorIndicator: FC = () => {
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
@ -303,6 +328,7 @@ export const ConnectorIndicator: FC = () => {
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>

View file

@ -17,6 +17,7 @@ interface ConnectorCardProps {
isConnected?: boolean;
isConnecting?: boolean;
documentCount?: number;
accountCount?: number;
lastIndexedAt?: string | null;
isIndexing?: boolean;
activeTask?: LogActiveTask;
@ -96,6 +97,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
isConnected = false,
isConnecting = false,
documentCount,
accountCount,
lastIndexedAt,
isIndexing = false,
activeTask,
@ -139,7 +141,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors flex-shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
@ -150,12 +152,20 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span>
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
{accountCount !== undefined && accountCount > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</>
)}
</p>
)}
</div>
@ -163,7 +173,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium",
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs"

View file

@ -143,12 +143,14 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Connector header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
<div className="flex items-center gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<div className="flex gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 shrink-0">
{getConnectorIcon(connector.connector_type, "size-7")}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">{connector.name}</h2>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{connector.name}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
</p>

View file

@ -1,14 +1,16 @@
"use client";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import type { IndexingConfigState } from "../../constants/connector-constants";
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
interface IndexingConfigurationViewProps {
@ -89,12 +91,14 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
};
}, [checkScrollState]);
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -111,14 +115,19 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
)}
{/* Success header */}
<div className="flex items-center gap-4 mb-6">
<div className="flex gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-green-500/10 border border-green-500/20">
<Check className="size-7 text-green-500" />
</div>
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
{config.connectorTitle} Connected!
</h2>
<div className="flex flex-col">
<span className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
</span>{" "}
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorDisplayName(connector?.name || "")}
</span>
</div>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Configure when to start syncing your data
</p>

View file

@ -7,11 +7,12 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
error: z.string().optional(),
});
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;

View file

@ -66,6 +66,12 @@ export const useConnectorDialog = () => {
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const isCreatingConnectorRef = useRef(false);
// Accounts list view state (for OAuth connectors with multiple accounts)
const [viewingAccountsType, setViewingAccountsType] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -114,24 +120,50 @@ export const useConnectorDialog = () => {
setConnectingConnectorType(null);
}
// Clear viewing accounts type if view is not "accounts" anymore
if (params.view !== "accounts" && viewingAccountsType) {
setViewingAccountsType(null);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
// Handle accounts view
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === params.connectorType
);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
}
// Handle YouTube view
if (params.view === "youtube") {
// YouTube view is active - no additional state needed
}
if (params.view === "configure" && params.connector && !indexingConfig) {
// Handle configure view (for page refresh support)
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector && allConnectors) {
const existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (oauthConnector) {
let existingConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.id === connectorId
);
} else {
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
}
if (existingConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
@ -200,6 +232,10 @@ export const useConnectorDialog = () => {
if (connectingConnectorType) {
setConnectingConnectorType(null);
}
// Clear viewing accounts type when modal is closed
if (viewingAccountsType) {
setViewingAccountsType(null);
}
// Clear YouTube view when modal is closed (handled by view param check)
}
} catch (error) {
@ -207,13 +243,48 @@ export const useConnectorDialog = () => {
console.warn("Invalid connector popup query params:", error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]);
}, [
searchParams,
allConnectors,
editingConnector,
indexingConfig,
connectingConnectorType,
viewingAccountsType,
]);
// Detect OAuth success and transition to config view
// Detect OAuth success / Failure and transition to config view
useEffect(() => {
try {
const params = parseConnectorPopupQueryParams(searchParams);
// Handle OAuth errors (e.g., duplicate account)
if (params.error && params.modal === "connectors") {
const oauthConnector = params.connector
? OAUTH_CONNECTORS.find((c) => c.id === params.connector)
: null;
const connectorName = oauthConnector?.title || "connector";
if (params.error === "duplicate_account") {
toast.error(`This ${connectorName} account is already connected`, {
description: "Please use a different account or manage the existing connection.",
});
} else {
toast.error(`Failed to connect ${connectorName}`, {
description: params.error.replace(/_/g, " "),
});
}
// Clean up error params from URL
const url = new URL(window.location.href);
url.searchParams.delete("error");
url.searchParams.delete("connector");
window.history.replaceState({}, "", url.toString());
// Open the popup to show the connectors
setIsOpen(true);
return;
}
if (
params.success === "true" &&
params.connector &&
@ -225,11 +296,17 @@ export const useConnectorDialog = () => {
refetchAllConnectors().then((result) => {
if (!result.data) return;
const newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
let newConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
} else {
newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
}
if (newConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
@ -243,6 +320,7 @@ export const useConnectorDialog = () => {
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
} else {
@ -632,6 +710,38 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback(
(connectorType: string, connectorTitle: string) => {
if (!searchSpaceId) return;
setViewingAccountsType({
connectorType,
connectorTitle,
});
// Update URL to show accounts view, preserving current tab
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId]
);
// Handle going back from accounts list view
const handleBackFromAccountsList = useCallback(() => {
setViewingAccountsType(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
// Keep the current tab (don't change it) - just remove view-specific params
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
@ -1081,6 +1191,7 @@ export const useConnectorDialog = () => {
setConnectorName(null);
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
@ -1126,6 +1237,7 @@ export const useConnectorDialog = () => {
frequencyMinutes,
searchSpaceId,
allConnectors,
viewingAccountsType,
// Setters
setSearchQuery,
@ -1152,6 +1264,8 @@ export const useConnectorDialog = () => {
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,

View file

@ -11,6 +11,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
interface ActiveConnectorsTabProps {
@ -24,6 +25,7 @@ interface ActiveConnectorsTabProps {
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
@ -36,6 +38,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchSpaceId,
onTabChange,
onManage,
onViewAccountsList,
}) => {
const router = useRouter();
@ -71,38 +74,26 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
if (minutesAgo < 1) return "Just now";
if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
return format(date, "MMM d, yyyy");
};
// Document types that should be shown as cards (not from connectors)
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
// Get most recent last indexed date from a list of connectors
const getMostRecentLastIndexed = (
connectorsList: SearchSourceConnector[]
): string | undefined => {
return connectorsList.reduce<string | undefined>((latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
}, undefined);
};
// Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
// Filter to only show standalone document types that have documents (count > 0)
@ -118,8 +109,54 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return doc.label.toLowerCase().includes(searchQuery.toLowerCase());
});
// Filter connectors based on search query
const filteredConnectors = connectors.filter((connector) => {
// Get OAuth connector types set for quick lookup
const oauthConnectorTypes = new Set<string>(OAUTH_CONNECTORS.map((c) => c.connectorType));
// Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
(acc, connector) => {
const type = connector.connector_type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(connector);
return acc;
},
{} as Record<string, SearchSourceConnector[]>
);
// Get display info for OAuth connector type
const getOAuthConnectorTypeInfo = (connectorType: string) => {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
return {
title:
oauthConnector?.title ||
connectorType
.replace(/_/g, " ")
.replace(/connector/gi, "")
.trim(),
};
};
// Filter OAuth connector types based on search query
const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(
([connectorType]) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
const { title } = getOAuthConnectorTypeInfo(connectorType);
return (
title.toLowerCase().includes(searchLower) ||
connectorType.toLowerCase().includes(searchLower)
);
}
);
// Filter non-OAuth connectors based on search query
const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
@ -128,18 +165,97 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
);
});
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
return (
<TabsContent value="active" className="m-0">
{hasSources ? (
<div className="space-y-6">
{/* Active Connectors Section */}
{filteredConnectors.length > 0 && (
{hasActiveConnectors && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">Active Connectors</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredConnectors.map((connector) => {
{/* OAuth Connectors - Grouped by Type */}
{filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => {
const { title } = getOAuthConnectorTypeInfo(connectorType);
const isAnyIndexing = typeConnectors.some((c: SearchSourceConnector) =>
indexingConnectorIds.has(c.id)
);
const documentCount = getDocumentCountForConnector(
connectorType,
documentTypeCounts
);
const accountCount = typeConnectors.length;
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
const handleManageClick = () => {
if (onViewAccountsList) {
onViewAccountsList(connectorType, title);
} else if (onManage && typeConnectors[0]) {
onManage(typeConnectors[0]);
}
};
return (
<div
key={`oauth-type-${connectorType}`}
className={cn(
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isAnyIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isAnyIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connectorType, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={handleManageClick}
>
Manage
</Button>
</div>
);
})}
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
@ -161,7 +277,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border",
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
@ -197,7 +313,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={onManage ? () => onManage(connector) : undefined}
>
Manage

View file

@ -1,12 +1,27 @@
"use client";
import { Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { ConnectorCard } from "../components/connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
/**
* Extract the display name from a full connector name.
* Full names are in format "Base Name - identifier" (e.g., "Gmail - john@example.com").
* Returns just the identifier (e.g : john@example.com).
*/
export function getConnectorDisplayName(fullName: string): string {
const separatorIndex = fullName.indexOf(" - ");
if (separatorIndex !== -1) {
return fullName.substring(separatorIndex + 3);
}
return fullName;
}
interface AllConnectorsTabProps {
searchQuery: string;
searchSpaceId: string;
@ -21,6 +36,7 @@ interface AllConnectorsTabProps {
onCreateWebcrawler?: () => void;
onCreateYouTubeCrawler?: () => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
@ -37,6 +53,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onCreateWebcrawler,
onCreateYouTubeCrawler,
onManage,
onViewAccountsList,
}) => {
// Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
@ -77,22 +94,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
{filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector =
// Find all connectors of this type
const typeConnectors =
isConnected && allConnectors
? allConnectors.find(
? allConnectors.filter(
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
)
: undefined;
: [];
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest)
? c.last_indexed_at
: latest;
},
undefined
);
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
// Get active task from any indexing account
const activeTask = typeConnectors
.map((c) => getActiveTaskForConnector(c.id))
.find((task) => task !== undefined);
return (
<ConnectorCard
@ -104,12 +138,15 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
accountCount={typeConnectors.length}
lastIndexedAt={mostRecentLastIndexed}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined
isConnected && onViewAccountsList
? () => onViewAccountsList(connector.connectorType, connector.title)
: undefined
}
/>
);

View file

@ -0,0 +1,189 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
isConnecting?: boolean;
}
/**
* Format last indexed date with contextual messages
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) {
return "Just now";
}
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
return format(date, "MMM d, yyyy");
}
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorType,
connectorTitle,
connectors,
indexingConnectorIds,
logsSummary,
onBack,
onManage,
onAddAccount,
isConnecting = false,
}) => {
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 sm:px-12 pt-6 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
<div className="flex items-center justify-between gap-4 sm:pr-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-full shrink-0"
onClick={onBack}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{getConnectorIcon(connectorType, "size-5")}
</div>
<div>
<h2 className="text-lg font-semibold">{connectorTitle} Accounts</h2>
<p className="text-xs text-muted-foreground">
{typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
{/* Add Account Button with dashed border */}
<button
type="button"
onClick={onAddAccount}
disabled={isConnecting}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg mr-4 border-2 border-dashed border-border/70 text-left transition-all duration-200",
"border-primary/50 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
) : (
<Plus className="size-3.5 text-primary" />
)}
</div>
<span className="text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 sm:px-12 py-6 sm:py-8">
{/* Connected Accounts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connector.connector_type, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(connector.name)}
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[100px]">
{activeTask.message}
</span>
)}
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
</div>
);
})}
</div>
</div>
</div>
);
};

View file

@ -1,7 +1,7 @@
"use client";
import { Upload } from "lucide-react";
import { useAtomValue } from "jotai";
import { Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,