- {connector.connector_type === "MCP_CONNECTOR" ? "MCP Server" : connector.name}
+ {connector.name}
Manage your connector settings and sync configuration
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 7a2243705..4f56f588d 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -646,7 +646,7 @@ export const useConnectorDialog = () => {
const successMessage =
currentConnectorType === "MCP_CONNECTOR"
- ? `${connector.name} MCP server added successfully`
+ ? `${connector.name} added successfully`
: `${connectorTitle} connected and indexing started!`;
toast.success(successMessage, {
description: periodicEnabledForIndexing
@@ -711,7 +711,7 @@ export const useConnectorDialog = () => {
// Other non-indexable connectors - just show success message and close
const successMessage =
currentConnectorType === "MCP_CONNECTOR"
- ? `${connector.name} MCP server added successfully`
+ ? `${connector.name} added successfully`
: `${connectorTitle} connected successfully!`;
toast.success(successMessage);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index 6152504fc..2487b7276 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -1,6 +1,7 @@
"use client";
import type { FC } from "react";
+import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { ConnectorCard } from "../components/connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
@@ -161,6 +162,16 @@ export const AllConnectorsTab: FC = ({
);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
+ // For MCP connectors, count total MCP connectors instead of document count
+ const isMCP = connector.connectorType === EnumConnectorName.MCP_CONNECTOR;
+ const mcpConnectorCount =
+ isMCP && allConnectors
+ ? allConnectors.filter(
+ (c: SearchSourceConnector) =>
+ c.connector_type === EnumConnectorName.MCP_CONNECTOR
+ ).length
+ : undefined;
+
const handleConnect = onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
: () => {}; // Fallback - connector popup should handle all connector types
@@ -175,6 +186,7 @@ export const AllConnectorsTab: FC = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
+ connectorCount={mcpConnectorCount}
isIndexing={isIndexing}
onConnect={handleConnect}
onManage={
diff --git a/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts b/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
index e03d76445..650a95e3d 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
@@ -138,35 +138,37 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
// Replace technical error messages with user-friendly ones
if (errorMsg.includes("expected string, received undefined")) {
- errorMsg = "This field is required";
+ errorMsg = fieldPath ? `The '${fieldPath}' field is required` : "This field is required";
} else if (errorMsg.includes("Invalid input")) {
- errorMsg = "Invalid value";
+ errorMsg = fieldPath ? `The '${fieldPath}' field has an invalid value` : "Invalid value";
+ } else if (fieldPath && !errorMsg.toLowerCase().includes(fieldPath.toLowerCase())) {
+ // If error message doesn't mention the field name, prepend it
+ errorMsg = `The '${fieldPath}' field: ${errorMsg}`;
}
- const formattedError = fieldPath ? `${fieldPath}: ${errorMsg}` : errorMsg;
-
- console.error("[MCP Validator] ❌ Validation error:", formattedError);
+ console.error("[MCP Validator] ❌ Validation error:", errorMsg);
console.error("[MCP Validator] Full Zod errors:", result.error.issues);
return {
config: null,
- error: formattedError,
+ error: errorMsg,
};
}
// Build config based on transport type
- const config: MCPServerConfig = result.data.transport === "stdio" || !result.data.transport
- ? {
- command: (result.data as z.infer).command,
- args: (result.data as z.infer).args,
- env: (result.data as z.infer).env,
- transport: "stdio" as const,
- }
- : {
- url: (result.data as z.infer).url,
- headers: (result.data as z.infer).headers,
- transport: result.data.transport as "streamable-http" | "http" | "sse",
- };
+ const config: MCPServerConfig =
+ result.data.transport === "stdio" || !result.data.transport
+ ? {
+ command: (result.data as z.infer).command,
+ args: (result.data as z.infer).args,
+ env: (result.data as z.infer).env,
+ transport: "stdio" as const,
+ }
+ : {
+ url: (result.data as z.infer).url,
+ headers: (result.data as z.infer).headers,
+ transport: result.data.transport as "streamable-http" | "http" | "sse",
+ };
// Cache the successfully parsed config
configCache.set(configJson, {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
index 5f8c1f3ed..a48ca02e6 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
@@ -1,9 +1,10 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
-import { ArrowLeft, Loader2, Plus } from "lucide-react";
+import { ArrowLeft, Loader2, Plus, Server } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
+import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
@@ -19,6 +20,7 @@ interface ConnectorAccountsListViewProps {
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
isConnecting?: boolean;
+ addButtonText?: string;
}
/**
@@ -70,6 +72,7 @@ export const ConnectorAccountsListView: FC = ({
onManage,
onAddAccount,
isConnecting = false,
+ addButtonText,
}) => {
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
@@ -80,6 +83,22 @@ export const ConnectorAccountsListView: FC = ({
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
+ // Determine button text - default to "Add Account" unless specified
+ const buttonText =
+ addButtonText ||
+ (connectorType === EnumConnectorName.MCP_CONNECTOR ? "Add New MCP Server" : "Add Account");
+ const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
+
+ // Helper to get display name for connector (handles MCP server name extraction)
+ const getDisplayName = (connector: SearchSourceConnector): string => {
+ if (isMCP) {
+ // For MCP, extract server name from config if available
+ const serverName = connector.config?.server_config?.name || connector.name;
+ return serverName;
+ }
+ return getConnectorDisplayName(connector.name);
+ };
+
return (
{/* Header */}
@@ -115,22 +134,22 @@ export const ConnectorAccountsListView: FC
= ({
onClick={onAddAccount}
disabled={isConnecting || !isEnabled}
className={cn(
- "flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg border-2 border-dashed text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
+ "flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border-2 border-dashed text-xs sm:text-sm transition-all duration-200 shrink-0 w-full sm:w-auto",
!isEnabled
? "border-border/30 opacity-50 cursor-not-allowed"
- : "border-primary/50 hover:bg-primary/5",
+ : "border-slate-400/20 dark:border-white/20 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
-
+
{isConnecting ? (
-
+
) : (
-
+
)}
-
- {isConnecting ? "Connecting" : "Add Account"}
+
+ {isConnecting ? "Connecting" : buttonText}
@@ -139,61 +158,81 @@ export const ConnectorAccountsListView: FC = ({
{/* Content */}
{/* Connected Accounts Grid */}
-
- {typeConnectors.map((connector) => {
- const isIndexing = indexingConnectorIds.has(connector.id);
+ {typeConnectors.length === 0 ? (
+
+
+ {isMCP ? (
+
+ ) : (
+ getConnectorIcon(connectorType, "size-8")
+ )}
+
+
+ {isMCP ? "No MCP Servers" : `No ${connectorTitle} Accounts`}
+
+
+ {isMCP
+ ? "Get started by adding your first Model Context Protocol server"
+ : `Get started by connecting your first ${connectorTitle} account`}
+
+
+ ) : (
+
+ {typeConnectors.map((connector) => {
+ const isIndexing = indexingConnectorIds.has(connector.id);
- return (
-
+ return (
- {getConnectorIcon(connector.connector_type, "size-6")}
-
-
-
- {getConnectorDisplayName(connector.name)}
-
- {isIndexing ? (
-
-
- Syncing
+
+ {getConnectorIcon(connector.connector_type, "size-6")}
+
+
+
+ {getDisplayName(connector)}
- ) : (
-
- {isIndexableConnector(connector.connector_type)
- ? connector.last_indexed_at
- ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
- : "Never indexed"
- : "Active"}
-
- )}
+ {isIndexing ? (
+
+
+ Syncing
+
+ ) : (
+
+ {isIndexableConnector(connector.connector_type)
+ ? connector.last_indexed_at
+ ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
+ : "Never indexed"
+ : "Active"}
+
+ )}
+
+
-
-
- );
- })}
-
+ );
+ })}
+
+ )}
);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
deleted file mode 100644
index 78a0b0b0c..000000000
--- a/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-"use client";
-
-import { Plus, Server, XCircle } from "lucide-react";
-import type { FC } from "react";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import { EnumConnectorName } from "@/contracts/enums/connector";
-import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
-import type { SearchSourceConnector } from "@/contracts/types/connector.types";
-import { cn } from "@/lib/utils";
-
-interface MCPConnectorListViewProps {
- mcpConnectors: SearchSourceConnector[];
- onAddNew: () => void;
- onManageConnector: (connector: SearchSourceConnector) => void;
- onBack: () => void;
-}
-
-export const MCPConnectorListView: FC = ({
- mcpConnectors,
- onAddNew,
- onManageConnector,
- onBack,
-}) => {
- // Validate that all connectors are MCP connectors
- const invalidConnectors = mcpConnectors.filter(
- (c) => c.connector_type !== EnumConnectorName.MCP_CONNECTOR
- );
-
- if (invalidConnectors.length > 0) {
- console.error(
- "MCPConnectorListView received non-MCP connectors:",
- invalidConnectors.map((c) => c.connector_type)
- );
- return (
-
-
- Invalid Connector Type
-
- This view can only display MCP connectors. Found {invalidConnectors.length} invalid
- connector(s).
-
-
- );
- }
- return (
-
- {/* Header */}
-
-
-
-
-
MCP Connectors
-
- Manage your Model Context Protocol servers
-
-
-
-
-
- {/* Add New Button */}
-
-
-
-
- {/* MCP Connectors List */}
-
- {mcpConnectors.length === 0 ? (
-
-
-
-
-
No MCP Servers
-
- Get started by adding your first Model Context Protocol server
-
-
- ) : (
- mcpConnectors.map((connector) => {
- // Extract server name from config
- const serverName = connector.config?.server_config?.name || connector.name;
-
- return (
-
-
- {getConnectorIcon("MCP_CONNECTOR", "size-6")}
-
-
-
-
- );
- })
- )}
-
-
- );
-};
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index 570440f6a..ae8fe9b8d 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -11,8 +11,8 @@ import {
useState,
} from "react";
import ReactDOMServer from "react-dom/server";
-import type { Document } from "@/contracts/types/document.types";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import type { Document } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
export interface MentionedDocument {
diff --git a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
index 2ec960614..12f336d85 100644
--- a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
+++ b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
@@ -36,10 +36,12 @@ export function CommentPanel({
if (isLoading) {
return (
-
+
Loading comments...
@@ -57,10 +59,7 @@ export function CommentPanel({
return (
{hasThreads && (
@@ -92,11 +91,7 @@ export function CommentPanel({
)}
-
+
{isComposerOpen ? (
{/* Drag handle indicator - only for bottom sheet */}
@@ -37,10 +30,7 @@ export function CommentSheet({
)}
-
+
Comments
@@ -52,11 +42,7 @@ export function CommentSheet({
-
+
diff --git a/surfsense_web/components/connectors/google-drive-folder-tree.tsx b/surfsense_web/components/connectors/google-drive-folder-tree.tsx
index 894564167..30df2d788 100644
--- a/surfsense_web/components/connectors/google-drive-folder-tree.tsx
+++ b/surfsense_web/components/connectors/google-drive-folder-tree.tsx
@@ -4,6 +4,7 @@ import {
ChevronDown,
ChevronRight,
File,
+ FileSpreadsheet,
FileText,
FolderClosed,
FolderOpen,
@@ -11,7 +12,6 @@ import {
Image,
Loader2,
Presentation,
- FileSpreadsheet,
} from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx
index 1981bba68..f4d324cfb 100644
--- a/surfsense_web/components/layout/ui/header/Header.tsx
+++ b/surfsense_web/components/layout/ui/header/Header.tsx
@@ -28,21 +28,22 @@ export function Header({
const hasThread = isChatPage && currentThreadState.id !== null;
// Create minimal thread object for ChatShareButton (used for API calls)
- const threadForButton: ThreadRecord | null = hasThread
- ? {
- id: currentThreadState.id!,
- visibility: currentThreadState.visibility ?? "PRIVATE",
- // These fields are not used by ChatShareButton for display, only for checks
- created_by_id: null,
- search_space_id: 0,
- title: "",
- archived: false,
- created_at: "",
- updated_at: "",
- }
- : null;
+ const threadForButton: ThreadRecord | null =
+ hasThread && currentThreadState.id !== null
+ ? {
+ id: currentThreadState.id,
+ visibility: currentThreadState.visibility ?? "PRIVATE",
+ // These fields are not used by ChatShareButton for display, only for checks
+ created_by_id: null,
+ search_space_id: 0,
+ title: "",
+ archived: false,
+ created_at: "",
+ updated_at: "",
+ }
+ : null;
- const handleVisibilityChange = (visibility: ChatVisibility) => {
+ const handleVisibilityChange = (_visibility: ChatVisibility) => {
// Visibility change is handled by ChatShareButton internally via Jotai
// This callback can be used for additional side effects if needed
};
diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx
index e9f5db2dc..acecc06af 100644
--- a/surfsense_web/components/notifications/NotificationButton.tsx
+++ b/surfsense_web/components/notifications/NotificationButton.tsx
@@ -1,16 +1,16 @@
"use client";
-import { useState } from "react";
+import { useAtomValue } from "jotai";
import { Bell } from "lucide-react";
+import { useParams } from "next/navigation";
+import { useState } from "react";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications } from "@/hooks/use-notifications";
-import { useAtomValue } from "jotai";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { NotificationPopup } from "./NotificationPopup";
import { cn } from "@/lib/utils";
-import { useParams } from "next/navigation";
+import { NotificationPopup } from "./NotificationPopup";
export function NotificationButton() {
const [open, setOpen] = useState(false);
diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx
index 9196ceaa4..50deadf03 100644
--- a/surfsense_web/components/notifications/NotificationPopup.tsx
+++ b/surfsense_web/components/notifications/NotificationPopup.tsx
@@ -1,14 +1,14 @@
"use client";
-import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
+import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
-import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
-import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
interface NotificationPopupProps {
notifications: Notification[];
diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx
index af3046a64..e31885973 100644
--- a/surfsense_web/components/providers/ElectricProvider.tsx
+++ b/surfsense_web/components/providers/ElectricProvider.tsx
@@ -1,13 +1,13 @@
"use client";
-import { useEffect, useState, useRef } from "react";
import { useAtomValue } from "jotai";
+import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
- initElectric,
cleanupElectric,
- isElectricInitialized,
type ElectricClient,
+ initElectric,
+ isElectricInitialized,
} from "@/lib/electric/client";
import { ElectricContext } from "@/lib/electric/context";
diff --git a/surfsense_web/content/docs/docker-installation.mdx b/surfsense_web/content/docs/docker-installation.mdx
index 6501c7783..ec8cb246b 100644
--- a/surfsense_web/content/docs/docker-installation.mdx
+++ b/surfsense_web/content/docs/docker-installation.mdx
@@ -26,7 +26,7 @@ Make sure to include the `-v surfsense-data:/data` in your Docker command. This
**Linux/macOS:**
```bash
-docker run -d -p 3000:3000 -p 8000:8000 \
+docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
--name surfsense \
--restart unless-stopped \
@@ -36,7 +36,7 @@ docker run -d -p 3000:3000 -p 8000:8000 \
**Windows (PowerShell):**
```powershell
-docker run -d -p 3000:3000 -p 8000:8000 `
+docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 `
-v surfsense-data:/data `
--name surfsense `
--restart unless-stopped `
@@ -50,7 +50,7 @@ docker run -d -p 3000:3000 -p 8000:8000 `
You can pass any [environment variable](/docs/manual-installation#backend-environment-variables) using `-e` flags:
```bash
-docker run -d -p 3000:3000 -p 8000:8000 \
+docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 \
-v surfsense-data:/data \
-e EMBEDDING_MODEL=openai://text-embedding-ada-002 \
-e OPENAI_API_KEY=your_openai_api_key \
@@ -93,6 +93,7 @@ After starting, access SurfSense at:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend API**: [http://localhost:8000](http://localhost:8000)
- **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
+- **Electric-SQL**: [http://localhost:5133](http://localhost:5133)
### Quick Start Environment Variables
@@ -195,6 +196,11 @@ Before you begin, ensure you have:
| NEXT_PUBLIC_FASTAPI_BACKEND_URL | URL of the backend API (used by frontend during build and runtime) | http://localhost:8000 |
| NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE | Authentication method for frontend: `LOCAL` or `GOOGLE` | LOCAL |
| NEXT_PUBLIC_ETL_SERVICE | Document parsing service for frontend UI: `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING` | DOCLING |
+| ELECTRIC_PORT | Port for Electric-SQL service | 5133 |
+| POSTGRES_HOST | PostgreSQL host for Electric connection (`db` for Docker PostgreSQL, `host.docker.internal` for local PostgreSQL) | db |
+| ELECTRIC_DB_USER | PostgreSQL username for Electric connection | electric |
+| ELECTRIC_DB_PASSWORD | PostgreSQL password for Electric connection | electric_password |
+| NEXT_PUBLIC_ELECTRIC_URL | URL for Electric-SQL service (used by frontend) | http://localhost:5133 |
**Note:** Frontend environment variables with the `NEXT_PUBLIC_` prefix are embedded into the Next.js production build at build time. Since the frontend now runs as a production build in Docker, these variables must be set in the root `.env` file (Docker-specific configuration) and will be passed as build arguments during the Docker build process.
@@ -209,7 +215,8 @@ Before you begin, ensure you have:
| AUTH_TYPE | Authentication method: `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication |
| GOOGLE_OAUTH_CLIENT_ID | (Optional) Client ID from Google Cloud Console (required if AUTH_TYPE=GOOGLE) |
| GOOGLE_OAUTH_CLIENT_SECRET | (Optional) Client secret from Google Cloud Console (required if AUTH_TYPE=GOOGLE) |
-| GOOGLE_DRIVE_REDIRECT_URI | (Optional) Redirect URI for Google Drive connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/drive/connector/callback`). Required when using Google Drive connector. |
+| ELECTRIC_DB_USER | (Optional) PostgreSQL username for Electric-SQL connection (default: `electric`) |
+| ELECTRIC_DB_PASSWORD | (Optional) PostgreSQL password for Electric-SQL connection (default: `electric_password`) |
| EMBEDDING_MODEL | Name of the embedding model (e.g., `sentence-transformers/all-MiniLM-L6-v2`, `openai://text-embedding-ada-002`) |
| RERANKERS_ENABLED | (Optional) Enable or disable document reranking for improved search results (e.g., `TRUE` or `FALSE`, default: `FALSE`) |
| RERANKERS_MODEL_NAME | Name of the reranker model (e.g., `ms-marco-MiniLM-L-12-v2`) (required if RERANKERS_ENABLED=TRUE) |
@@ -230,6 +237,44 @@ Before you begin, ensure you have:
| REGISTRATION_ENABLED | (Optional) Enable or disable new user registration (e.g., `TRUE` or `FALSE`, default: `TRUE`) |
| PAGES_LIMIT | (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version) |
+**Google Connector OAuth Configuration:**
+| ENV VARIABLE | DESCRIPTION |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| GOOGLE_CALENDAR_REDIRECT_URI | (Optional) Redirect URI for Google Calendar connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/calendar/connector/callback`) |
+| GOOGLE_GMAIL_REDIRECT_URI | (Optional) Redirect URI for Gmail connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/gmail/connector/callback`) |
+| GOOGLE_DRIVE_REDIRECT_URI | (Optional) Redirect URI for Google Drive connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/drive/connector/callback`) |
+
+**Connector OAuth Configurations (Optional):**
+
+| ENV VARIABLE | DESCRIPTION |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| AIRTABLE_CLIENT_ID | (Optional) Airtable OAuth client ID from [Airtable Developer Hub](https://airtable.com/create/oauth) |
+| AIRTABLE_CLIENT_SECRET | (Optional) Airtable OAuth client secret |
+| AIRTABLE_REDIRECT_URI | (Optional) Redirect URI for Airtable connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/airtable/connector/callback`) |
+| CLICKUP_CLIENT_ID | (Optional) ClickUp OAuth client ID |
+| CLICKUP_CLIENT_SECRET | (Optional) ClickUp OAuth client secret |
+| CLICKUP_REDIRECT_URI | (Optional) Redirect URI for ClickUp connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/clickup/connector/callback`) |
+| DISCORD_CLIENT_ID | (Optional) Discord OAuth client ID |
+| DISCORD_CLIENT_SECRET | (Optional) Discord OAuth client secret |
+| DISCORD_REDIRECT_URI | (Optional) Redirect URI for Discord connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/discord/connector/callback`) |
+| DISCORD_BOT_TOKEN | (Optional) Discord bot token from Developer Portal |
+| ATLASSIAN_CLIENT_ID | (Optional) Atlassian OAuth client ID (for Jira and Confluence) |
+| ATLASSIAN_CLIENT_SECRET | (Optional) Atlassian OAuth client secret |
+| JIRA_REDIRECT_URI | (Optional) Redirect URI for Jira connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/jira/connector/callback`) |
+| CONFLUENCE_REDIRECT_URI | (Optional) Redirect URI for Confluence connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/confluence/connector/callback`) |
+| LINEAR_CLIENT_ID | (Optional) Linear OAuth client ID |
+| LINEAR_CLIENT_SECRET | (Optional) Linear OAuth client secret |
+| LINEAR_REDIRECT_URI | (Optional) Redirect URI for Linear connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/linear/connector/callback`) |
+| NOTION_CLIENT_ID | (Optional) Notion OAuth client ID |
+| NOTION_CLIENT_SECRET | (Optional) Notion OAuth client secret |
+| NOTION_REDIRECT_URI | (Optional) Redirect URI for Notion connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/notion/connector/callback`) |
+| SLACK_CLIENT_ID | (Optional) Slack OAuth client ID |
+| SLACK_CLIENT_SECRET | (Optional) Slack OAuth client secret |
+| SLACK_REDIRECT_URI | (Optional) Redirect URI for Slack connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/slack/connector/callback`) |
+| TEAMS_CLIENT_ID | (Optional) Microsoft Teams OAuth client ID |
+| TEAMS_CLIENT_SECRET | (Optional) Microsoft Teams OAuth client secret |
+| TEAMS_REDIRECT_URI | (Optional) Redirect URI for Teams connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/teams/connector/callback`) |
+
**Optional Backend LangSmith Observability:**
| ENV VARIABLE | DESCRIPTION |
@@ -282,6 +327,8 @@ For more details, see the [Uvicorn documentation](https://www.uvicorn.org/#comma
- `NEXT_PUBLIC_FASTAPI_BACKEND_URL` - URL of the backend service
- `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` - Authentication method (`LOCAL` or `GOOGLE`)
- `NEXT_PUBLIC_ETL_SERVICE` - Document parsing service (should match backend `ETL_SERVICE`)
+- `NEXT_PUBLIC_ELECTRIC_URL` - URL for Electric-SQL service (default: `http://localhost:5133`)
+- `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` - Electric-SQL authentication mode (default: `insecure`)
These variables are embedded into the application during the Docker build process and affect the frontend's behavior and available features.
@@ -312,6 +359,7 @@ These variables are embedded into the application during the Docker build proces
- Frontend: [http://localhost:3000](http://localhost:3000)
- Backend API: [http://localhost:8000](http://localhost:8000)
- API Documentation: [http://localhost:8000/docs](http://localhost:8000/docs)
+ - Electric-SQL: [http://localhost:5133](http://localhost:5133)
- pgAdmin: [http://localhost:5050](http://localhost:5050)
## Docker Services Overview
@@ -322,6 +370,7 @@ The Docker setup includes several services that work together:
- **Frontend**: Next.js web application
- **PostgreSQL (db)**: Database with pgvector extension
- **Redis**: Message broker for Celery
+- **Electric-SQL**: Real-time sync service for database operations
- **Celery Worker**: Handles background tasks (document processing, indexing, etc.)
- **Celery Beat**: Scheduler for periodic tasks (enables scheduled connector indexing)
- The schedule interval can be configured using the `SCHEDULE_CHECKER_INTERVAL` environment variable in your backend `.env` file
diff --git a/surfsense_web/content/docs/manual-installation.mdx b/surfsense_web/content/docs/manual-installation.mdx
index 0dd703758..b4da781ba 100644
--- a/surfsense_web/content/docs/manual-installation.mdx
+++ b/surfsense_web/content/docs/manual-installation.mdx
@@ -72,7 +72,8 @@ Edit the `.env` file and set the following variables:
| AUTH_TYPE | Authentication method: `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication |
| GOOGLE_OAUTH_CLIENT_ID | (Optional) Client ID from Google Cloud Console (required if AUTH_TYPE=GOOGLE) |
| GOOGLE_OAUTH_CLIENT_SECRET | (Optional) Client secret from Google Cloud Console (required if AUTH_TYPE=GOOGLE) |
-| GOOGLE_DRIVE_REDIRECT_URI | (Optional) Redirect URI for Google Drive connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/drive/connector/callback`). Required when using Google Drive connector. |
+| ELECTRIC_DB_USER | (Optional) PostgreSQL username for Electric-SQL connection (default: `electric`) |
+| ELECTRIC_DB_PASSWORD | (Optional) PostgreSQL password for Electric-SQL connection (default: `electric_password`) |
| EMBEDDING_MODEL | Name of the embedding model (e.g., `sentence-transformers/all-MiniLM-L6-v2`, `openai://text-embedding-ada-002`) |
| RERANKERS_ENABLED | (Optional) Enable or disable document reranking for improved search results (e.g., `TRUE` or `FALSE`, default: `FALSE`) |
| RERANKERS_MODEL_NAME | Name of the reranker model (e.g., `ms-marco-MiniLM-L-12-v2`) (required if RERANKERS_ENABLED=TRUE) |
@@ -83,6 +84,7 @@ Edit the `.env` file and set the following variables:
| STT_SERVICE | Speech-to-Text API provider for Audio Files (e.g., `local/base`, `openai/whisper-1`). See [supported providers](https://docs.litellm.ai/docs/audio_transcription#supported-providers) |
| STT_SERVICE_API_KEY | (Optional if local) API key for the Speech-to-Text service |
| STT_SERVICE_API_BASE | (Optional) Custom API base URL for the Speech-to-Text service |
+| FIRECRAWL_API_KEY | (Optional) API key for Firecrawl service for web crawling |
| ETL_SERVICE | Document parsing service: `UNSTRUCTURED` (supports 34+ formats), `LLAMACLOUD` (supports 50+ formats including legacy document types), or `DOCLING` (local processing, supports PDF, Office docs, images, HTML, CSV) |
| UNSTRUCTURED_API_KEY | API key for Unstructured.io service for document parsing (required if ETL_SERVICE=UNSTRUCTURED) |
| LLAMA_CLOUD_API_KEY | API key for LlamaCloud service for document parsing (required if ETL_SERVICE=LLAMACLOUD) |
@@ -92,6 +94,43 @@ Edit the `.env` file and set the following variables:
| REGISTRATION_ENABLED | (Optional) Enable or disable new user registration (e.g., `TRUE` or `FALSE`, default: `TRUE`) |
| PAGES_LIMIT | (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version) |
+**Google Connector OAuth Configuration:**
+| ENV VARIABLE | DESCRIPTION |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| GOOGLE_CALENDAR_REDIRECT_URI | (Optional) Redirect URI for Google Calendar connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/calendar/connector/callback`) |
+| GOOGLE_GMAIL_REDIRECT_URI | (Optional) Redirect URI for Gmail connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/gmail/connector/callback`) |
+| GOOGLE_DRIVE_REDIRECT_URI | (Optional) Redirect URI for Google Drive connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/google/drive/connector/callback`) |
+
+**Connector OAuth Configurations (Optional):**
+
+| ENV VARIABLE | DESCRIPTION |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| AIRTABLE_CLIENT_ID | (Optional) Airtable OAuth client ID from [Airtable Developer Hub](https://airtable.com/create/oauth) |
+| AIRTABLE_CLIENT_SECRET | (Optional) Airtable OAuth client secret |
+| AIRTABLE_REDIRECT_URI | (Optional) Redirect URI for Airtable connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/airtable/connector/callback`) |
+| CLICKUP_CLIENT_ID | (Optional) ClickUp OAuth client ID |
+| CLICKUP_CLIENT_SECRET | (Optional) ClickUp OAuth client secret |
+| CLICKUP_REDIRECT_URI | (Optional) Redirect URI for ClickUp connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/clickup/connector/callback`) |
+| DISCORD_CLIENT_ID | (Optional) Discord OAuth client ID |
+| DISCORD_CLIENT_SECRET | (Optional) Discord OAuth client secret |
+| DISCORD_REDIRECT_URI | (Optional) Redirect URI for Discord connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/discord/connector/callback`) |
+| DISCORD_BOT_TOKEN | (Optional) Discord bot token from Developer Portal |
+| ATLASSIAN_CLIENT_ID | (Optional) Atlassian OAuth client ID (for Jira and Confluence) |
+| ATLASSIAN_CLIENT_SECRET | (Optional) Atlassian OAuth client secret |
+| JIRA_REDIRECT_URI | (Optional) Redirect URI for Jira connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/jira/connector/callback`) |
+| CONFLUENCE_REDIRECT_URI | (Optional) Redirect URI for Confluence connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/confluence/connector/callback`) |
+| LINEAR_CLIENT_ID | (Optional) Linear OAuth client ID |
+| LINEAR_CLIENT_SECRET | (Optional) Linear OAuth client secret |
+| LINEAR_REDIRECT_URI | (Optional) Redirect URI for Linear connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/linear/connector/callback`) |
+| NOTION_CLIENT_ID | (Optional) Notion OAuth client ID |
+| NOTION_CLIENT_SECRET | (Optional) Notion OAuth client secret |
+| NOTION_REDIRECT_URI | (Optional) Redirect URI for Notion connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/notion/connector/callback`) |
+| SLACK_CLIENT_ID | (Optional) Slack OAuth client ID |
+| SLACK_CLIENT_SECRET | (Optional) Slack OAuth client secret |
+| SLACK_REDIRECT_URI | (Optional) Redirect URI for Slack connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/slack/connector/callback`) |
+| TEAMS_CLIENT_ID | (Optional) Microsoft Teams OAuth client ID |
+| TEAMS_CLIENT_SECRET | (Optional) Microsoft Teams OAuth client secret |
+| TEAMS_REDIRECT_URI | (Optional) Redirect URI for Teams connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/teams/connector/callback`) |
**(Optional) Backend LangSmith Observability:**
| ENV VARIABLE | DESCRIPTION |
@@ -368,6 +407,8 @@ Edit the `.env` file and set:
| NEXT_PUBLIC_FASTAPI_BACKEND_URL | Backend URL (e.g., `http://localhost:8000`) |
| NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE | Same value as set in backend AUTH_TYPE i.e `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication |
| NEXT_PUBLIC_ETL_SERVICE | Document parsing service (should match backend ETL_SERVICE): `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING` - affects supported file formats in upload interface |
+| NEXT_PUBLIC_ELECTRIC_URL | URL for Electric-SQL service (e.g., `http://localhost:5133`) |
+| NEXT_PUBLIC_ELECTRIC_AUTH_MODE | Electric-SQL authentication mode (default: `insecure`) |
### 2. Install Dependencies
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 5f147b63b..9350b6a1e 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -65,7 +65,7 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return ;
case EnumConnectorName.MCP_CONNECTOR:
- return ;
+ return ;
// Additional cases for non-enum connector types
case "YOUTUBE_CONNECTOR":
return ;
diff --git a/surfsense_web/hooks/use-connectors-electric.ts b/surfsense_web/hooks/use-connectors-electric.ts
index 94d5062c9..08ef0621d 100644
--- a/surfsense_web/hooks/use-connectors-electric.ts
+++ b/surfsense_web/hooks/use-connectors-electric.ts
@@ -1,9 +1,9 @@
"use client";
-import { useEffect, useState, useCallback, useRef } from "react";
-import { useElectricClient } from "@/lib/electric/context";
-import type { SyncHandle } from "@/lib/electric/client";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
/**
* Hook for managing connectors with Electric SQL real-time sync
diff --git a/surfsense_web/hooks/use-documents-electric.ts b/surfsense_web/hooks/use-documents-electric.ts
index 74d9e91e7..43809499e 100644
--- a/surfsense_web/hooks/use-documents-electric.ts
+++ b/surfsense_web/hooks/use-documents-electric.ts
@@ -1,8 +1,8 @@
"use client";
-import { useEffect, useState, useRef, useMemo } from "react";
-import { useElectricClient } from "@/lib/electric/context";
+import { useEffect, useMemo, useRef, useState } from "react";
import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
interface Document {
id: number;
diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts
index 7a3b49861..fbdf421de 100644
--- a/surfsense_web/hooks/use-notifications.ts
+++ b/surfsense_web/hooks/use-notifications.ts
@@ -1,10 +1,10 @@
"use client";
-import { useEffect, useState, useCallback, useRef } from "react";
-import { useElectricClient } from "@/lib/electric/context";
-import type { SyncHandle } from "@/lib/electric/client";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
export type { Notification } from "@/contracts/types/notification.types";
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index c33969914..514185d23 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -13,8 +13,8 @@
*/
import { PGlite } from "@electric-sql/pglite";
-import { electricSync } from "@electric-sql/pglite-sync";
import { live } from "@electric-sql/pglite/live";
+import { electricSync } from "@electric-sql/pglite-sync";
// Types
export interface ElectricClient {
@@ -270,365 +270,375 @@ export async function initElectric(userId: string): Promise {
// Create and track the sync promise to prevent race conditions
const syncPromise = (async (): Promise => {
// Build params for the shape request
- // Electric SQL expects params as URL query parameters
- const params: Record = { table };
+ // Electric SQL expects params as URL query parameters
+ const params: Record = { table };
- // Validate and fix WHERE clause to ensure string literals are properly quoted
- let validatedWhere = where;
- if (where) {
- // Check if where uses positional parameters
- if (where.includes("$1")) {
- // Extract the value from the where clause if it's embedded
- // For now, we'll use the where clause as-is and let Electric handle it
- params.where = where;
- validatedWhere = where;
- } else {
- // Validate that string literals are properly quoted
- // Count single quotes - should be even (pairs) for properly quoted strings
- const singleQuoteCount = (where.match(/'/g) || []).length;
-
- if (singleQuoteCount % 2 !== 0) {
- // Odd number of quotes means unterminated string literal
- console.warn("Where clause has unmatched quotes, fixing:", where);
- // Add closing quote at the end
- validatedWhere = `${where}'`;
- params.where = validatedWhere;
- } else {
- // Use the where clause directly (already formatted)
+ // Validate and fix WHERE clause to ensure string literals are properly quoted
+ let validatedWhere = where;
+ if (where) {
+ // Check if where uses positional parameters
+ if (where.includes("$1")) {
+ // Extract the value from the where clause if it's embedded
+ // For now, we'll use the where clause as-is and let Electric handle it
params.where = where;
validatedWhere = where;
+ } else {
+ // Validate that string literals are properly quoted
+ // Count single quotes - should be even (pairs) for properly quoted strings
+ const singleQuoteCount = (where.match(/'/g) || []).length;
+
+ if (singleQuoteCount % 2 !== 0) {
+ // Odd number of quotes means unterminated string literal
+ console.warn("Where clause has unmatched quotes, fixing:", where);
+ // Add closing quote at the end
+ validatedWhere = `${where}'`;
+ params.where = validatedWhere;
+ } else {
+ // Use the where clause directly (already formatted)
+ params.where = where;
+ validatedWhere = where;
+ }
}
}
- }
- if (columns) params.columns = columns.join(",");
+ if (columns) params.columns = columns.join(",");
- console.log("[Electric] Syncing shape with params:", params);
- console.log("[Electric] Electric URL:", `${electricUrl}/v1/shape`);
- console.log("[Electric] Where clause:", where, "Validated:", validatedWhere);
+ console.log("[Electric] Syncing shape with params:", params);
+ console.log("[Electric] Electric URL:", `${electricUrl}/v1/shape`);
+ console.log("[Electric] Where clause:", where, "Validated:", validatedWhere);
- try {
- // Debug: Test Electric SQL connection directly first
- // Use validatedWhere to ensure proper URL encoding
- const testUrl = `${electricUrl}/v1/shape?table=${table}&offset=-1${validatedWhere ? `&where=${encodeURIComponent(validatedWhere)}` : ""}`;
- console.log("[Electric] Testing Electric SQL directly:", testUrl);
try {
- const testResponse = await fetch(testUrl);
- const testHeaders = {
- handle: testResponse.headers.get("electric-handle"),
- offset: testResponse.headers.get("electric-offset"),
- upToDate: testResponse.headers.get("electric-up-to-date"),
- };
- console.log("[Electric] Direct Electric SQL response headers:", testHeaders);
- const testData = await testResponse.json();
- console.log(
- "[Electric] Direct Electric SQL data count:",
- Array.isArray(testData) ? testData.length : "not array",
- testData
- );
- } catch (testErr) {
- console.error("[Electric] Direct Electric SQL test failed:", testErr);
- }
-
- // Use PGlite's electric sync plugin to sync the shape
- // According to Electric SQL docs, the shape config uses params for table, where, columns
- // Note: mapColumns is OPTIONAL per pglite-sync types.ts
-
- // Create a promise that resolves when initial sync is complete
- // Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout
- // IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates
- let syncResolved = false;
- // Initialize with no-op functions to satisfy TypeScript
- let resolveInitialSync: () => void = () => {};
- let rejectInitialSync: (error: Error) => void = () => {};
-
- const initialSyncPromise = new Promise((resolve, reject) => {
- resolveInitialSync = () => {
- if (!syncResolved) {
- syncResolved = true;
- // DON'T unsubscribe from stream - it needs to stay active for real-time updates
- resolve();
- }
- };
- rejectInitialSync = (error: Error) => {
- if (!syncResolved) {
- syncResolved = true;
- // DON'T unsubscribe from stream even on error - let Electric handle it
- reject(error);
- }
- };
-
- // Shorter timeout (5 seconds) as fallback
- setTimeout(() => {
- if (!syncResolved) {
- console.warn(
- `[Electric] ⚠️ Sync timeout for ${table} - checking isUpToDate one more time...`
- );
- // Check isUpToDate one more time before resolving
- // This will be checked after shape is created
- setTimeout(() => {
- if (!syncResolved) {
- console.warn(
- `[Electric] ⚠️ Sync timeout for ${table} - resolving anyway after 5s`
- );
- resolveInitialSync();
- }
- }, 100);
- }
- }, 5000);
- });
-
- // Include userId in shapeKey for user-specific sync state
- const shapeConfig = {
- shape: {
- url: `${electricUrl}/v1/shape`,
- params: {
- table,
- ...(validatedWhere ? { where: validatedWhere } : {}),
- ...(columns ? { columns: columns.join(",") } : {}),
- },
- },
- table,
- primaryKey,
- shapeKey: `${userId}_v${SYNC_VERSION}_${table}_${where?.replace(/[^a-zA-Z0-9]/g, "_") || "all"}`, // User-specific versioned key
- onInitialSync: () => {
+ // Debug: Test Electric SQL connection directly first
+ // Use validatedWhere to ensure proper URL encoding
+ const testUrl = `${electricUrl}/v1/shape?table=${table}&offset=-1${validatedWhere ? `&where=${encodeURIComponent(validatedWhere)}` : ""}`;
+ console.log("[Electric] Testing Electric SQL directly:", testUrl);
+ try {
+ const testResponse = await fetch(testUrl);
+ const testHeaders = {
+ handle: testResponse.headers.get("electric-handle"),
+ offset: testResponse.headers.get("electric-offset"),
+ upToDate: testResponse.headers.get("electric-up-to-date"),
+ };
+ console.log("[Electric] Direct Electric SQL response headers:", testHeaders);
+ const testData = await testResponse.json();
console.log(
- `[Electric] ✅ Initial sync complete for ${table} - data should now be in PGlite`
+ "[Electric] Direct Electric SQL data count:",
+ Array.isArray(testData) ? testData.length : "not array",
+ testData
+ );
+ } catch (testErr) {
+ console.error("[Electric] Direct Electric SQL test failed:", testErr);
+ }
+
+ // Use PGlite's electric sync plugin to sync the shape
+ // According to Electric SQL docs, the shape config uses params for table, where, columns
+ // Note: mapColumns is OPTIONAL per pglite-sync types.ts
+
+ // Create a promise that resolves when initial sync is complete
+ // Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout
+ // IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates
+ let syncResolved = false;
+ // Initialize with no-op functions to satisfy TypeScript
+ let resolveInitialSync: () => void = () => {};
+ let rejectInitialSync: (error: Error) => void = () => {};
+
+ const initialSyncPromise = new Promise((resolve, reject) => {
+ resolveInitialSync = () => {
+ if (!syncResolved) {
+ syncResolved = true;
+ // DON'T unsubscribe from stream - it needs to stay active for real-time updates
+ resolve();
+ }
+ };
+ rejectInitialSync = (error: Error) => {
+ if (!syncResolved) {
+ syncResolved = true;
+ // DON'T unsubscribe from stream even on error - let Electric handle it
+ reject(error);
+ }
+ };
+
+ // Shorter timeout (5 seconds) as fallback
+ setTimeout(() => {
+ if (!syncResolved) {
+ console.warn(
+ `[Electric] ⚠️ Sync timeout for ${table} - checking isUpToDate one more time...`
+ );
+ // Check isUpToDate one more time before resolving
+ // This will be checked after shape is created
+ setTimeout(() => {
+ if (!syncResolved) {
+ console.warn(
+ `[Electric] ⚠️ Sync timeout for ${table} - resolving anyway after 5s`
+ );
+ resolveInitialSync();
+ }
+ }, 100);
+ }
+ }, 5000);
+ });
+
+ // Include userId in shapeKey for user-specific sync state
+ const shapeConfig = {
+ shape: {
+ url: `${electricUrl}/v1/shape`,
+ params: {
+ table,
+ ...(validatedWhere ? { where: validatedWhere } : {}),
+ ...(columns ? { columns: columns.join(",") } : {}),
+ },
+ },
+ table,
+ primaryKey,
+ shapeKey: `${userId}_v${SYNC_VERSION}_${table}_${where?.replace(/[^a-zA-Z0-9]/g, "_") || "all"}`, // User-specific versioned key
+ onInitialSync: () => {
+ console.log(
+ `[Electric] ✅ Initial sync complete for ${table} - data should now be in PGlite`
+ );
+ resolveInitialSync();
+ },
+ onError: (error: Error) => {
+ console.error(`[Electric] ❌ Shape sync error for ${table}:`, error);
+ console.error(
+ "[Electric] Error details:",
+ JSON.stringify(error, Object.getOwnPropertyNames(error))
+ );
+ rejectInitialSync(error);
+ },
+ };
+
+ console.log(
+ "[Electric] syncShapeToTable config:",
+ JSON.stringify(shapeConfig, null, 2)
+ );
+
+ // Type assertion to PGlite with electric extension
+ const pgWithElectric = db as PGlite & {
+ electric: {
+ syncShapeToTable: (
+ config: typeof shapeConfig
+ ) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
+ };
+ };
+
+ let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown };
+ try {
+ shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
+ } catch (syncError) {
+ // Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet
+ const errorMessage =
+ syncError instanceof Error ? syncError.message : String(syncError);
+ if (errorMessage.includes("Already syncing")) {
+ console.warn(
+ `[Electric] Already syncing ${table}, waiting for existing sync to settle...`
+ );
+
+ // Wait a short time for pglite-sync to settle
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Check if an active handle now exists (another sync might have completed)
+ const existingHandle = activeSyncHandles.get(cacheKey);
+ if (existingHandle) {
+ console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`);
+ return existingHandle;
+ }
+
+ // Retry once after waiting
+ console.log(`[Electric] Retrying sync for ${table}...`);
+ try {
+ shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
+ } catch (retryError) {
+ const retryMessage =
+ retryError instanceof Error ? retryError.message : String(retryError);
+ if (retryMessage.includes("Already syncing")) {
+ // Still syncing - create a placeholder handle that indicates the table is being synced
+ console.warn(
+ `[Electric] ${table} still syncing, creating placeholder handle`
+ );
+ const placeholderHandle: SyncHandle = {
+ unsubscribe: () => {
+ console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`);
+ activeSyncHandles.delete(cacheKey);
+ },
+ get isUpToDate() {
+ return false; // We don't know the real state
+ },
+ stream: undefined,
+ initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming
+ };
+ activeSyncHandles.set(cacheKey, placeholderHandle);
+ return placeholderHandle;
+ }
+ throw retryError;
+ }
+ } else {
+ throw syncError;
+ }
+ }
+
+ if (!shape) {
+ throw new Error("syncShapeToTable returned undefined");
+ }
+
+ // Log the actual shape result structure
+ console.log("[Electric] Shape sync result (initial):", {
+ hasUnsubscribe: typeof shape?.unsubscribe === "function",
+ isUpToDate: shape?.isUpToDate,
+ hasStream: !!shape?.stream,
+ streamType: typeof shape?.stream,
+ });
+
+ // Recommended Approach Step 1: Check isUpToDate immediately
+ if (shape.isUpToDate) {
+ console.log(
+ `[Electric] ✅ Sync already up-to-date for ${table} (resuming from previous state)`
);
resolveInitialSync();
- },
- onError: (error: Error) => {
- console.error(`[Electric] ❌ Shape sync error for ${table}:`, error);
- console.error(
- "[Electric] Error details:",
- JSON.stringify(error, Object.getOwnPropertyNames(error))
- );
- rejectInitialSync(error);
- },
- };
-
- console.log(
- "[Electric] syncShapeToTable config:",
- JSON.stringify(shapeConfig, null, 2)
- );
-
- // Type assertion to PGlite with electric extension
- const pgWithElectric = db as PGlite & {
- electric: {
- syncShapeToTable: (
- config: typeof shapeConfig
- ) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
- };
- };
-
- let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown };
- try {
- shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
- } catch (syncError) {
- // Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet
- const errorMessage = syncError instanceof Error ? syncError.message : String(syncError);
- if (errorMessage.includes("Already syncing")) {
- console.warn(`[Electric] Already syncing ${table}, waiting for existing sync to settle...`);
-
- // Wait a short time for pglite-sync to settle
- await new Promise(resolve => setTimeout(resolve, 100));
-
- // Check if an active handle now exists (another sync might have completed)
- const existingHandle = activeSyncHandles.get(cacheKey);
- if (existingHandle) {
- console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`);
- return existingHandle;
- }
-
- // Retry once after waiting
- console.log(`[Electric] Retrying sync for ${table}...`);
- try {
- shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
- } catch (retryError) {
- const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
- if (retryMessage.includes("Already syncing")) {
- // Still syncing - create a placeholder handle that indicates the table is being synced
- console.warn(`[Electric] ${table} still syncing, creating placeholder handle`);
- const placeholderHandle: SyncHandle = {
- unsubscribe: () => {
- console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`);
- activeSyncHandles.delete(cacheKey);
- },
- get isUpToDate() {
- return false; // We don't know the real state
- },
- stream: undefined,
- initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming
- };
- activeSyncHandles.set(cacheKey, placeholderHandle);
- return placeholderHandle;
- }
- throw retryError;
- }
} else {
- throw syncError;
- }
- }
-
- if (!shape) {
- throw new Error("syncShapeToTable returned undefined");
- }
-
- // Log the actual shape result structure
- console.log("[Electric] Shape sync result (initial):", {
- hasUnsubscribe: typeof shape?.unsubscribe === "function",
- isUpToDate: shape?.isUpToDate,
- hasStream: !!shape?.stream,
- streamType: typeof shape?.stream,
- });
-
- // Recommended Approach Step 1: Check isUpToDate immediately
- if (shape.isUpToDate) {
- console.log(
- `[Electric] ✅ Sync already up-to-date for ${table} (resuming from previous state)`
- );
- resolveInitialSync();
- } else {
- // Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message
- if (shape?.stream) {
- const stream = shape.stream as any;
- console.log("[Electric] Shape stream details:", {
- shapeHandle: stream?.shapeHandle,
- lastOffset: stream?.lastOffset,
- isUpToDate: stream?.isUpToDate,
- error: stream?.error,
- hasSubscribe: typeof stream?.subscribe === "function",
- hasUnsubscribe: typeof stream?.unsubscribe === "function",
- });
-
- // Subscribe to the stream to watch for "up-to-date" control message
- // NOTE: We keep this subscription active - don't unsubscribe!
- // The stream is what Electric SQL uses for real-time updates
- if (typeof stream?.subscribe === "function") {
- console.log(
- "[Electric] Subscribing to shape stream to watch for up-to-date message..."
- );
- // Subscribe but don't store unsubscribe - we want it to stay active
- stream.subscribe((messages: unknown[]) => {
- // Continue receiving updates even after sync is resolved
- if (!syncResolved) {
- console.log(
- "[Electric] 🔵 Shape stream received messages:",
- messages?.length || 0
- );
- }
-
- // Check if any message indicates sync is complete
- if (messages && messages.length > 0) {
- for (const message of messages) {
- const msg = message as any;
- // Check for "up-to-date" control message
- if (
- msg?.headers?.control === "up-to-date" ||
- msg?.headers?.electric_up_to_date === "true" ||
- (typeof msg === "object" && "up-to-date" in msg)
- ) {
- if (!syncResolved) {
- console.log(`[Electric] ✅ Received up-to-date message for ${table}`);
- resolveInitialSync();
- }
- // Continue listening for real-time updates - don't return!
- }
- }
- if (!syncResolved && messages.length > 0) {
- console.log(
- "[Electric] First message:",
- JSON.stringify(messages[0], null, 2)
- );
- }
- }
-
- // Also check stream's isUpToDate property after receiving messages
- if (!syncResolved && stream?.isUpToDate) {
- console.log(`[Electric] ✅ Stream isUpToDate is true for ${table}`);
- resolveInitialSync();
- }
+ // Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message
+ if (shape?.stream) {
+ const stream = shape.stream as any;
+ console.log("[Electric] Shape stream details:", {
+ shapeHandle: stream?.shapeHandle,
+ lastOffset: stream?.lastOffset,
+ isUpToDate: stream?.isUpToDate,
+ error: stream?.error,
+ hasSubscribe: typeof stream?.subscribe === "function",
+ hasUnsubscribe: typeof stream?.unsubscribe === "function",
});
- // Also check stream's isUpToDate property immediately
- if (stream?.isUpToDate) {
- console.log(`[Electric] ✅ Stream isUpToDate is true immediately for ${table}`);
- resolveInitialSync();
+ // Subscribe to the stream to watch for "up-to-date" control message
+ // NOTE: We keep this subscription active - don't unsubscribe!
+ // The stream is what Electric SQL uses for real-time updates
+ if (typeof stream?.subscribe === "function") {
+ console.log(
+ "[Electric] Subscribing to shape stream to watch for up-to-date message..."
+ );
+ // Subscribe but don't store unsubscribe - we want it to stay active
+ stream.subscribe((messages: unknown[]) => {
+ // Continue receiving updates even after sync is resolved
+ if (!syncResolved) {
+ console.log(
+ "[Electric] 🔵 Shape stream received messages:",
+ messages?.length || 0
+ );
+ }
+
+ // Check if any message indicates sync is complete
+ if (messages && messages.length > 0) {
+ for (const message of messages) {
+ const msg = message as any;
+ // Check for "up-to-date" control message
+ if (
+ msg?.headers?.control === "up-to-date" ||
+ msg?.headers?.electric_up_to_date === "true" ||
+ (typeof msg === "object" && "up-to-date" in msg)
+ ) {
+ if (!syncResolved) {
+ console.log(`[Electric] ✅ Received up-to-date message for ${table}`);
+ resolveInitialSync();
+ }
+ // Continue listening for real-time updates - don't return!
+ }
+ }
+ if (!syncResolved && messages.length > 0) {
+ console.log(
+ "[Electric] First message:",
+ JSON.stringify(messages[0], null, 2)
+ );
+ }
+ }
+
+ // Also check stream's isUpToDate property after receiving messages
+ if (!syncResolved && stream?.isUpToDate) {
+ console.log(`[Electric] ✅ Stream isUpToDate is true for ${table}`);
+ resolveInitialSync();
+ }
+ });
+
+ // Also check stream's isUpToDate property immediately
+ if (stream?.isUpToDate) {
+ console.log(
+ `[Electric] ✅ Stream isUpToDate is true immediately for ${table}`
+ );
+ resolveInitialSync();
+ }
}
+
+ // Also poll isUpToDate periodically as a backup (every 200ms)
+ const pollInterval = setInterval(() => {
+ if (syncResolved) {
+ clearInterval(pollInterval);
+ return;
+ }
+
+ if (shape.isUpToDate || stream?.isUpToDate) {
+ console.log(
+ `[Electric] ✅ Sync completed (detected via polling) for ${table}`
+ );
+ clearInterval(pollInterval);
+ resolveInitialSync();
+ }
+ }, 200);
+
+ // Clean up polling when promise resolves
+ initialSyncPromise.finally(() => {
+ clearInterval(pollInterval);
+ });
+ } else {
+ console.warn(
+ `[Electric] ⚠️ No stream available for ${table}, relying on callback and timeout`
+ );
}
-
- // Also poll isUpToDate periodically as a backup (every 200ms)
- const pollInterval = setInterval(() => {
- if (syncResolved) {
- clearInterval(pollInterval);
- return;
- }
-
- if (shape.isUpToDate || stream?.isUpToDate) {
- console.log(`[Electric] ✅ Sync completed (detected via polling) for ${table}`);
- clearInterval(pollInterval);
- resolveInitialSync();
- }
- }, 200);
-
- // Clean up polling when promise resolves
- initialSyncPromise.finally(() => {
- clearInterval(pollInterval);
- });
- } else {
- console.warn(
- `[Electric] ⚠️ No stream available for ${table}, relying on callback and timeout`
- );
}
- }
- // Create the sync handle with proper cleanup
- const syncHandle: SyncHandle = {
- unsubscribe: () => {
- console.log(`[Electric] Unsubscribing from: ${cacheKey}`);
- // Remove from cache first
- activeSyncHandles.delete(cacheKey);
- // Then unsubscribe from the shape
- if (shape && typeof shape.unsubscribe === "function") {
- shape.unsubscribe();
- }
- },
- // Use getter to always return current state
- get isUpToDate() {
- return shape?.isUpToDate ?? false;
- },
- stream: shape?.stream,
- initialSyncPromise, // Expose promise so callers can wait for sync
- };
+ // Create the sync handle with proper cleanup
+ const syncHandle: SyncHandle = {
+ unsubscribe: () => {
+ console.log(`[Electric] Unsubscribing from: ${cacheKey}`);
+ // Remove from cache first
+ activeSyncHandles.delete(cacheKey);
+ // Then unsubscribe from the shape
+ if (shape && typeof shape.unsubscribe === "function") {
+ shape.unsubscribe();
+ }
+ },
+ // Use getter to always return current state
+ get isUpToDate() {
+ return shape?.isUpToDate ?? false;
+ },
+ stream: shape?.stream,
+ initialSyncPromise, // Expose promise so callers can wait for sync
+ };
- // Cache the sync handle for reuse (memory optimization)
- activeSyncHandles.set(cacheKey, syncHandle);
- console.log(
- `[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})`
- );
-
- return syncHandle;
- } catch (error) {
- console.error("[Electric] Failed to sync shape:", error);
- // Check if Electric SQL server is reachable
- try {
- const response = await fetch(`${electricUrl}/v1/shape?table=${table}&offset=-1`, {
- method: "GET",
- });
+ // Cache the sync handle for reuse (memory optimization)
+ activeSyncHandles.set(cacheKey, syncHandle);
console.log(
- "[Electric] Electric SQL server response:",
- response.status,
- response.statusText
+ `[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})`
);
- if (!response.ok) {
- console.error("[Electric] Electric SQL server error:", await response.text());
+
+ return syncHandle;
+ } catch (error) {
+ console.error("[Electric] Failed to sync shape:", error);
+ // Check if Electric SQL server is reachable
+ try {
+ const response = await fetch(`${electricUrl}/v1/shape?table=${table}&offset=-1`, {
+ method: "GET",
+ });
+ console.log(
+ "[Electric] Electric SQL server response:",
+ response.status,
+ response.statusText
+ );
+ if (!response.ok) {
+ console.error("[Electric] Electric SQL server error:", await response.text());
+ }
+ } catch (fetchError) {
+ console.error("[Electric] Cannot reach Electric SQL server:", fetchError);
+ console.error("[Electric] Make sure Electric SQL is running at:", electricUrl);
}
- } catch (fetchError) {
- console.error("[Electric] Cannot reach Electric SQL server:", fetchError);
- console.error("[Electric] Make sure Electric SQL is running at:", electricUrl);
+ throw error;
}
- throw error;
- }
})();
// Track the sync promise to prevent concurrent syncs for the same shape
diff --git a/surfsense_web/public/connectors/modelcontextprotocol.svg b/surfsense_web/public/connectors/modelcontextprotocol.svg
new file mode 100644
index 000000000..e9c3fa46e
--- /dev/null
+++ b/surfsense_web/public/connectors/modelcontextprotocol.svg
@@ -0,0 +1 @@
+
\ No newline at end of file