feat: Enhance connector management UI with improved loading states, add document count display for connectors, and implement indexing progress indicators for better user feedback.

This commit is contained in:
Anish Sarkar 2025-12-31 17:21:26 +05:30
parent 3ac806dcdf
commit 163df8fda7
10 changed files with 287 additions and 95 deletions

View file

@ -322,6 +322,9 @@ async def get_logs_summary(
document_id = (
log.log_metadata.get("document_id") if log.log_metadata else None
)
connector_id = (
log.log_metadata.get("connector_id") if log.log_metadata else None
)
summary["active_tasks"].append(
{
"id": log.id,
@ -330,6 +333,7 @@ async def get_logs_summary(
"started_at": log.created_at,
"source": log.source,
"document_id": document_id,
"connector_id": connector_id,
}
)

View file

@ -125,14 +125,22 @@ export default function DocumentsTable() {
setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
};
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshCurrentView = useCallback(async () => {
if (debouncedSearch.trim()) {
await refetchSearch();
} else {
await refetchDocuments();
if (isRefreshing) return;
setIsRefreshing(true);
try {
if (debouncedSearch.trim()) {
await refetchSearch();
} else {
await refetchDocuments();
}
toast.success(t("refresh_success") || "Documents refreshed");
} finally {
setIsRefreshing(false);
}
toast.success(t("refresh_success") || "Documents refreshed");
}, [debouncedSearch, refetchSearch, refetchDocuments, t]);
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
// Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, {
@ -230,8 +238,8 @@ export default function DocumentsTable() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Button onClick={refreshCurrentView} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")}
</Button>
</motion.div>

View file

@ -472,9 +472,18 @@ export default function LogsManagePage() {
}
};
const [isRefreshing, setIsRefreshing] = useState(false);
const [isSummaryRefreshing, setIsSummaryRefreshing] = useState(false);
const handleRefresh = async () => {
await Promise.all([refreshLogs(), refreshSummary()]);
toast.success("Logs refreshed");
if (isRefreshing) return;
setIsRefreshing(true);
try {
await Promise.all([refreshLogs(), refreshSummary()]);
toast.success("Logs refreshed");
} finally {
setIsRefreshing(false);
}
};
return (
@ -495,7 +504,16 @@ export default function LogsManagePage() {
summary={summary}
loading={summaryLoading}
error={summaryError?.message ?? null}
onRefresh={refreshSummary}
onRefresh={async () => {
if (isSummaryRefreshing) return;
setIsSummaryRefreshing(true);
try {
await refreshSummary();
} finally {
setIsSummaryRefreshing(false);
}
}}
isRefreshing={isSummaryRefreshing}
/>
{/* Logs Table Header */}
@ -509,8 +527,8 @@ export default function LogsManagePage() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
<Button onClick={handleRefresh} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")}
</Button>
</motion.div>
@ -546,11 +564,13 @@ function LogsSummaryDashboard({
loading,
error,
onRefresh,
isRefreshing = false,
}: {
summary: any;
loading: boolean;
error: string | null;
onRefresh: () => void;
onRefresh: () => void | Promise<void>;
isRefreshing?: boolean;
}) {
const t = useTranslations("logs");
if (loading) {
@ -581,7 +601,8 @@ function LogsSummaryDashboard({
<div className="flex flex-col items-center gap-2">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">{t("failed_load_summary")}</p>
<Button variant="outline" size="sm" onClick={onRefresh}>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("retry")}
</Button>
</div>

View file

@ -136,12 +136,8 @@ export const ConnectorIndicator: FC = () => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing"))
.map((task) => {
const match = task.source?.match(/connector[_-]?(\d+)/i);
return match ? parseInt(match[1], 10) : null;
})
.filter((id): id is number => id !== null)
.filter((task) => task.source?.includes("connector_indexing") && task.connector_id != null)
.map((task) => task.connector_id as number)
);
}, [logsSummary?.active_tasks]);
@ -261,19 +257,22 @@ export const ConnectorIndicator: FC = () => {
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-6 sm:px-12 py-6 sm:py-8 pb-16 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onManage={handleStartEdit}
/>
</TabsContent>
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onManage={handleStartEdit}
/>
</TabsContent>
<ActiveConnectorsTab
hasSources={hasSources}

View file

@ -1,9 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { FileText, Loader2 } from "lucide-react";
import { type FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
interface ConnectorCardProps {
id: string;
@ -12,10 +13,24 @@ interface ConnectorCardProps {
connectorType: string;
isConnected?: boolean;
isConnecting?: boolean;
documentCount?: number;
isIndexing?: boolean;
activeTask?: LogActiveTask;
onConnect?: () => void;
onManage?: () => void;
}
/**
* Extract a number from the active task message for display
* Looks for patterns like "45 indexed", "Processing 123", etc.
*/
function extractIndexedCount(message: string | undefined): number | null {
if (!message) return null;
// Try to find a number in the message
const match = message.match(/(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
export const ConnectorCard: FC<ConnectorCardProps> = ({
id,
title,
@ -23,9 +38,53 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
connectorType,
isConnected = false,
isConnecting = false,
documentCount,
isIndexing = false,
activeTask,
onConnect,
onManage,
}) => {
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
// Determine the status content to display
const getStatusContent = () => {
if (isIndexing) {
return (
<div className="flex items-center gap-2 w-full max-w-[200px]">
<span className="text-[11px] text-primary font-medium whitespace-nowrap">
{indexingCount !== null ? (
<>{indexingCount.toLocaleString()} indexed</>
) : (
"Syncing..."
)}
</span>
{/* Indeterminate progress bar with animation */}
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-primary/20">
<div className="absolute h-full bg-primary rounded-full animate-progress-indeterminate" />
</div>
</div>
);
}
if (isConnected) {
if (documentCount !== undefined && documentCount > 0) {
return (
<span className="inline-flex items-center gap-1.5">
<FileText className="size-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{documentCount.toLocaleString()} document{documentCount !== 1 ? "s" : ""}
</span>
</span>
);
}
// Fallback for connected but no documents yet
return <span className="whitespace-nowrap">No documents indexed</span>;
}
return description;
};
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">
@ -35,19 +94,21 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span>
</div>
<p className="text-[11px] text-muted-foreground truncate mt-1">
{isConnected ? "Connected" : description}
</p>
<div className="text-[11px] text-muted-foreground mt-1">
{getStatusContent()}
</div>
</div>
<Button
size="sm"
variant={isConnected ? "outline" : "default"}
className="h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium"
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting}
disabled={isConnecting || isIndexing}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : isIndexing ? (
"Syncing..."
) : isConnected ? (
"Manage"
) : (

View file

@ -1,8 +1,7 @@
"use client";
import { format } from "date-fns";
import { Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Cable, FileText, Loader2 } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
@ -32,11 +31,9 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
connectors,
indexingConnectorIds,
logsSummary,
searchSpaceId,
onTabChange,
onManage,
}) => {
const router = useRouter();
return (
<TabsContent value="active" className="m-0">
@ -60,8 +57,11 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<p className="text-[14px] font-semibold leading-tight">
{getDocumentTypeLabel(docType)}
</p>
<p className="text-[11px] text-muted-foreground mt-1">
{count as number} documents indexed
<p className="text-[11px] text-muted-foreground mt-1 inline-flex items-center gap-1.5">
<FileText className="size-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{(count as number).toLocaleString()} document{count !== 1 ? "s" : ""}
</span>
</p>
</div>
</div>
@ -69,9 +69,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{connectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) =>
task.source?.includes(`connector_${connector.id}`) ||
task.source?.includes(`connector-${connector.id}`)
(task: LogActiveTask) => task.connector_id === connector.id
);
return (

View file

@ -3,8 +3,10 @@
import { useRouter } from "next/navigation";
import { type FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import { ConnectorCard } from "../components/connector-card";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
interface AllConnectorsTabProps {
searchQuery: string;
@ -12,6 +14,9 @@ interface AllConnectorsTabProps {
connectedTypes: Set<string>;
connectingId: string | null;
allConnectors: SearchSourceConnector[] | undefined;
documentTypeCounts?: Record<string, number>;
indexingConnectorIds?: Set<number>;
logsSummary?: LogSummary;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
onConnectNonOAuth?: (connectorType: string) => void;
onCreateWebcrawler?: () => void;
@ -24,6 +29,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
connectedTypes,
connectingId,
allConnectors,
documentTypeCounts,
indexingConnectorIds,
logsSummary,
onConnectOAuth,
onConnectNonOAuth,
onCreateWebcrawler,
@ -31,6 +39,14 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
}) => {
const router = useRouter();
// Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
if (!logsSummary?.active_tasks) return undefined;
return logsSummary.active_tasks.find(
(task: LogActiveTask) => task.connector_id === connectorId
);
};
// Filter connectors based on search
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
@ -55,28 +71,35 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector = isConnected && allConnectors
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
: undefined;
{filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector = isConnected && allConnectors
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
: undefined;
const documentCount = getDocumentCountForConnector(connector.connectorType, documentTypeCounts);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
return (
<ConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
isConnecting={isConnecting}
onConnect={() => onConnectOAuth(connector)}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
})}
return (
<ConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
})}
</div>
</section>
)}
@ -109,34 +132,41 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const isClickUp = connector.id === "clickup-connector";
const isLuma = connector.id === "luma-connector";
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector = isConnected && allConnectors
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
: undefined;
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector = isConnected && allConnectors
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
: undefined;
const handleConnect = isWebcrawler && onCreateWebcrawler
? onCreateWebcrawler
: (isTavily || isSearxng || isLinkup || isBaidu || isLinear || isElasticsearch || isSlack || isDiscord || isNotion || isConfluence || isBookStack || isGithub || isJira || isClickUp || isLuma) && onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);
const documentCount = getDocumentCountForConnector(connector.connectorType, documentTypeCounts);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
return (
<ConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
isConnecting={isConnecting}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
})}
const handleConnect = isWebcrawler && onCreateWebcrawler
? onCreateWebcrawler
: (isTavily || isSearxng || isLinkup || isBaidu || isLinear || isElasticsearch || isSlack || isDiscord || isNotion || isConfluence || isBookStack || isGithub || isJira || isClickUp || isLuma) && onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);
return (
<ConnectorCard
key={connector.id}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
})}
</div>
</section>
)}

View file

@ -0,0 +1,64 @@
"use client";
/**
* Maps SearchSourceConnectorType to DocumentType for fetching document counts
*
* Note: Some connectors don't have a direct 1:1 mapping to document types:
* - Search API connectors (TAVILY_API, SEARXNG_API, etc.) don't index documents
* - WEBCRAWLER_CONNECTOR maps to CRAWLED_URL document type
* - GOOGLE_DRIVE_CONNECTOR maps to GOOGLE_DRIVE_FILE document type
*/
export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Direct mappings (connector type matches document type)
SLACK_CONNECTOR: "SLACK_CONNECTOR",
NOTION_CONNECTOR: "NOTION_CONNECTOR",
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",
DISCORD_CONNECTOR: "DISCORD_CONNECTOR",
JIRA_CONNECTOR: "JIRA_CONNECTOR",
CONFLUENCE_CONNECTOR: "CONFLUENCE_CONNECTOR",
CLICKUP_CONNECTOR: "CLICKUP_CONNECTOR",
GOOGLE_CALENDAR_CONNECTOR: "GOOGLE_CALENDAR_CONNECTOR",
GOOGLE_GMAIL_CONNECTOR: "GOOGLE_GMAIL_CONNECTOR",
AIRTABLE_CONNECTOR: "AIRTABLE_CONNECTOR",
LUMA_CONNECTOR: "LUMA_CONNECTOR",
ELASTICSEARCH_CONNECTOR: "ELASTICSEARCH_CONNECTOR",
BOOKSTACK_CONNECTOR: "BOOKSTACK_CONNECTOR",
// Special mappings (connector type differs from document type)
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
};
/**
* Get the document type for a given connector type
* Returns undefined if the connector doesn't index documents (e.g., search APIs)
*/
export function getDocumentTypeForConnector(
connectorType: string
): string | undefined {
return CONNECTOR_TO_DOCUMENT_TYPE[connectorType];
}
/**
* Get document count for a specific connector type from document type counts
*/
export function getDocumentCountForConnector(
connectorType: string,
documentTypeCounts: Record<string, number> | undefined
): number | undefined {
if (!documentTypeCounts) return undefined;
const documentType = getDocumentTypeForConnector(connectorType);
if (!documentType) return undefined;
return documentTypeCounts[documentType];
}
/**
* Check if a connector type is indexable (produces documents)
*/
export function isIndexableConnectorType(connectorType: string): boolean {
return connectorType in CONNECTOR_TO_DOCUMENT_TYPE;
}

View file

@ -86,6 +86,7 @@ export const logActiveTask = z.object({
started_at: z.string(),
source: z.string().nullable().optional(),
document_id: z.number().nullable().optional(),
connector_id: z.number().nullable().optional(),
});
export const logFailure = z.object({
id: z.number(),

View file

@ -65,10 +65,16 @@ module.exports = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
"progress-indeterminate": {
"0%": { left: "-33%", width: "33%" },
"50%": { width: "50%" },
"100%": { left: "100%", width: "33%" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"progress-indeterminate": "progress-indeterminate 1.5s ease-in-out infinite",
},
},
},