diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index daf41a0d984..4a6aab428ad 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -39,4 +39,4 @@ jobs: working-directory: ./packages/db env: DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || github.ref == 'refs/heads/dev' && secrets.DEV_DATABASE_URL || secrets.STAGING_DATABASE_URL }} - run: bunx drizzle-kit migrate --config=./drizzle.config.ts \ No newline at end of file + run: bun run ./scripts/migrate.ts \ No newline at end of file diff --git a/apps/sim/app/(landing)/components/collaboration/collaboration.tsx b/apps/sim/app/(landing)/components/collaboration/collaboration.tsx index e0590c76a2c..2667c14a3df 100644 --- a/apps/sim/app/(landing)/components/collaboration/collaboration.tsx +++ b/apps/sim/app/(landing)/components/collaboration/collaboration.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useId, useRef, useState } from 'react' import dynamic from 'next/dynamic' import Image from 'next/image' import Link from 'next/link' @@ -171,8 +171,8 @@ function YouCursor({ x, y, visible }: YouCursorProps) { * Collaboration section — team workflows and real-time collaboration. * * SEO: - * - `
`. - * - `

` for the section title. + * - `
` is the stable anchor for in-page navigation. + * - The section title `

` is linked via `aria-labelledby` using a `useId()`-generated id. * - Product visuals use `
` with `
` and descriptive `alt` text. * * GEO: @@ -181,41 +181,17 @@ function YouCursor({ x, y, visible }: YouCursorProps) { * - Reference "Sim" by name per capability ("Sim's real-time collaboration"). */ -const CURSOR_LERP_FACTOR = 0.3 - export default function Collaboration() { + const headingId = useId() const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }) const [isHovering, setIsHovering] = useState(false) const sectionRef = useRef(null) - const targetPos = useRef({ x: 0, y: 0 }) - const animationRef = useRef(0) - - useEffect(() => { - const animate = () => { - setCursorPos((prev) => ({ - x: prev.x + (targetPos.current.x - prev.x) * CURSOR_LERP_FACTOR, - y: prev.y + (targetPos.current.y - prev.y) * CURSOR_LERP_FACTOR, - })) - animationRef.current = requestAnimationFrame(animate) - } - - if (isHovering) { - animationRef.current = requestAnimationFrame(animate) - } - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - } - }, [isHovering]) const handleMouseMove = useCallback((e: React.MouseEvent) => { - targetPos.current = { x: e.clientX, y: e.clientY } + setCursorPos({ x: e.clientX, y: e.clientY }) }, []) const handleMouseEnter = useCallback((e: React.MouseEvent) => { - targetPos.current = { x: e.clientX, y: e.clientY } setCursorPos({ x: e.clientX, y: e.clientY }) setIsHovering(true) }, []) @@ -228,7 +204,7 @@ export default function Collaboration() {

Realtime diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 775df7afd08..7c06dc0d85e 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -14,7 +14,7 @@ interface FooterItem { } const PRODUCT_LINKS: FooterItem[] = [ - { label: 'Mothership', href: 'https://docs.sim.ai', external: true }, + { label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true }, { label: 'Workflows', href: 'https://docs.sim.ai', external: true }, { label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true }, { label: 'Tables', href: 'https://docs.sim.ai/tables', external: true }, diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index bd9b10d4d3d..cd9c5523231 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -197,4 +197,51 @@ describe('MCP Serve Route', () => { expect(headers['X-API-Key']).toBeUndefined() expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') }) + + describe('initialize protocol version negotiation', () => { + async function callInitialize(protocolVersion?: string) { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + const params: Record = { + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + } + if (protocolVersion !== undefined) params.protocolVersion = protocolVersion + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }), + }) + const res = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + return res.json() as Promise<{ result: { protocolVersion: string } }> + } + + it('echoes a supported client protocolVersion (2025-06-18)', async () => { + const body = await callInitialize('2025-06-18') + expect(body.result.protocolVersion).toBe('2025-06-18') + }) + + it('echoes a supported client protocolVersion (2024-11-05)', async () => { + const body = await callInitialize('2024-11-05') + expect(body.result.protocolVersion).toBe('2024-11-05') + }) + + it('falls back to SDK latest when client requests unknown version', async () => { + const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js') + const body = await callInitialize('2099-01-01') + expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + }) + + it('falls back to SDK latest when client omits protocolVersion', async () => { + const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js') + const body = await callInitialize(undefined) + expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + }) + }) }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 702c9a57cf4..d876dcd0ef2 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -11,8 +11,10 @@ import { type JSONRPCError, type JSONRPCMessage, type JSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, type ListToolsResult, type RequestId, + SUPPORTED_PROTOCOL_VERSIONS, type Tool, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' @@ -36,6 +38,17 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') +function negotiateProtocolVersion(rpcParams: unknown): string { + const requested = + rpcParams && typeof rpcParams === 'object' && 'protocolVersion' in rpcParams + ? (rpcParams as { protocolVersion?: unknown }).protocolVersion + : undefined + if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) { + return requested + } + return LATEST_PROTOCOL_VERSION +} + export const dynamic = 'force-dynamic' interface RouteParams { @@ -214,7 +227,7 @@ export const POST = withRouteHandler( switch (method) { case 'initialize': { const result: InitializeResult = { - protocolVersion: '2024-11-05', + protocolVersion: negotiateProtocolVersion(rpcParams), capabilities: { tools: {} }, serverInfo: { name: server.name, version: '1.0.0' }, } diff --git a/apps/sim/app/api/tools/hubspot/lists/route.ts b/apps/sim/app/api/tools/hubspot/lists/route.ts index 0ee11b7c043..ab7cf55230e 100644 --- a/apps/sim/app/api/tools/hubspot/lists/route.ts +++ b/apps/sim/app/api/tools/hubspot/lists/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { hubspotListsSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -27,6 +28,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { credentialId, objectTypeId, query } = parsed.data.query + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const authz = await authorizeCredentialUse(request, { credentialId, requireWorkflowIdForInternal: false, diff --git a/apps/sim/app/api/tools/hubspot/owners/route.ts b/apps/sim/app/api/tools/hubspot/owners/route.ts index da58d59b2bf..be34256def9 100644 --- a/apps/sim/app/api/tools/hubspot/owners/route.ts +++ b/apps/sim/app/api/tools/hubspot/owners/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { hubspotOwnersSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -27,6 +28,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { credentialId, query } = parsed.data.query + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const authz = await authorizeCredentialUse(request, { credentialId, requireWorkflowIdForInternal: false, diff --git a/apps/sim/app/api/tools/hubspot/pipelines/route.ts b/apps/sim/app/api/tools/hubspot/pipelines/route.ts index 7543120e571..fd9643bed3a 100644 --- a/apps/sim/app/api/tools/hubspot/pipelines/route.ts +++ b/apps/sim/app/api/tools/hubspot/pipelines/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { hubspotPipelinesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -33,6 +34,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { credentialId, objectType } = parsed.data.query + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const authz = await authorizeCredentialUse(request, { credentialId, requireWorkflowIdForInternal: false, diff --git a/apps/sim/app/api/tools/hubspot/properties/route.ts b/apps/sim/app/api/tools/hubspot/properties/route.ts index 1fafcaab6f0..e52185455fc 100644 --- a/apps/sim/app/api/tools/hubspot/properties/route.ts +++ b/apps/sim/app/api/tools/hubspot/properties/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -36,6 +37,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { credentialId, objectType, query } = parsed.data.query + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const authz = await authorizeCredentialUse(request, { credentialId, requireWorkflowIdForInternal: false, diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 33e6af90ae7..7fb17e17424 100644 --- a/apps/sim/app/api/tools/mongodb/utils.ts +++ b/apps/sim/app/api/tools/mongodb/utils.ts @@ -1,5 +1,8 @@ import { MongoClient } from 'mongodb' -import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { + createPinnedLookup, + validateDatabaseHost, +} from '@/lib/core/security/input-validation.server' import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types' export async function createMongoDBConnection(config: MongoDBConnectionConfig) { @@ -30,6 +33,7 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) { connectTimeoutMS: 10000, socketTimeoutMS: 10000, maxPoolSize: 1, + lookup: createPinnedLookup(hostValidation.resolvedIP ?? config.host), }) await client.connect() diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts index 30883aa7f2a..971bc31ba21 100644 --- a/apps/sim/app/api/tools/mysql/utils.ts +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -1,3 +1,4 @@ +import net from 'node:net' import mysql from 'mysql2/promise' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' @@ -16,12 +17,19 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) { throw new Error(hostValidation.error) } + const resolvedIP = hostValidation.resolvedIP ?? config.host + const connectionConfig: mysql.ConnectionOptions = { host: config.host, port: config.port, database: config.database, user: config.username, password: config.password, + stream: () => { + const socket = net.connect({ host: resolvedIP, port: config.port, timeout: 10000 }) + socket.setNoDelay(true) + return socket + }, } if (config.ssl === 'disabled') { diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts index f843d723a05..ac0bdf0eb0e 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -18,7 +18,14 @@ export async function createNeo4jDriver(config: Neo4jConnectionConfig) { protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' } - const uri = `${protocol}://${config.host}:${config.port}` + const useIPPinning = !protocol.endsWith('+s') + const resolvedIP = hostValidation.resolvedIP ?? config.host + const uriHost = useIPPinning + ? resolvedIP.includes(':') + ? `[${resolvedIP}]` + : resolvedIP + : config.host + const uri = `${protocol}://${uriHost}:${config.port}` const driverConfig: any = { maxConnectionPoolSize: 1, diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index 55f0bbe9304..dfeeab9eadb 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -8,17 +8,18 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) throw new Error(hostValidation.error) } - const sslConfig = + const resolvedHost = hostValidation.resolvedIP ?? config.host + const pinIP = config.ssl !== 'preferred' + + const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' ? false - : config.ssl === 'required' - ? 'require' - : config.ssl === 'preferred' - ? 'prefer' - : 'require' + : config.ssl === 'preferred' + ? 'prefer' + : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: config.host, + host: pinIP ? resolvedHost : config.host, port: config.port, database: config.database, username: config.username, diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index c3fb36c85b0..7a38c676b95 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -36,7 +36,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: hostValidation.error }, { status: 400 }) } - client = new Redis(url, { + const resolvedIP = hostValidation.resolvedIP ?? hostname + const tlsEnabled = parsedUrl.protocol === 'rediss:' + const port = parsedUrl.port ? Number(parsedUrl.port) : 6379 + const username = parsedUrl.username ? decodeURIComponent(parsedUrl.username) : undefined + const password = parsedUrl.password ? decodeURIComponent(parsedUrl.password) : undefined + + let db = 0 + if (parsedUrl.pathname && parsedUrl.pathname.length > 1) { + const dbSegment = parsedUrl.pathname.slice(1) + const parsedDb = Number.parseInt(dbSegment, 10) + if (!Number.isFinite(parsedDb) || String(parsedDb) !== dbSegment) { + return NextResponse.json( + { error: `Invalid Redis database index in URL path: '${dbSegment}'` }, + { status: 400 } + ) + } + db = parsedDb + } + + client = new Redis({ + host: resolvedIP, + port, + username, + password, + db, + family: resolvedIP.includes(':') ? 6 : 4, + tls: tlsEnabled ? { servername: hostname } : undefined, connectTimeout: 10000, commandTimeout: 10000, maxRetriesPerRequest: 1, diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index 38e476f6522..c23bba7d666 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -15,8 +15,16 @@ import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors' import type { ExecutionResult } from '@/lib/workflows/types' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' -const { mockDownloadFile, mockUploadFile, uploadedFiles } = vi.hoisted(() => ({ +const { + mockAddLargeValueReference, + mockDownloadFile, + mockRegisterLargeValueOwner, + mockUploadFile, + uploadedFiles, +} = vi.hoisted(() => ({ + mockAddLargeValueReference: vi.fn(), mockDownloadFile: vi.fn(), + mockRegisterLargeValueOwner: vi.fn(), mockUploadFile: vi.fn(), uploadedFiles: new Map(), })) @@ -35,6 +43,11 @@ vi.mock('@/lib/uploads', () => ({ }, })) +vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({ + addLargeValueReference: mockAddLargeValueReference, + registerLargeValueOwner: mockRegisterLargeValueOwner, +})) + function buildExecutionResult(overrides: Partial = {}): ExecutionResult { return { success: true, @@ -66,6 +79,8 @@ describe('Response block gating by auth type', () => { vi.clearAllMocks() clearLargeValueCacheForTests() uploadedFiles.clear() + mockAddLargeValueReference.mockResolvedValue(undefined) + mockRegisterLargeValueOwner.mockResolvedValue(true) mockUploadFile.mockImplementation(async ({ customKey, file }) => { uploadedFiles.set(customKey, file) return { key: customKey } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 65f71d7f516..301cdd26be0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -28,6 +28,7 @@ import { } from '@/components/emcn' import { Database, DatabaseX } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' +import { cn } from '@/lib/core/utils/cn' import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' @@ -920,19 +921,35 @@ export function KnowledgeBase({ const def = CONNECTOR_REGISTRY[connector.connectorType] const ConnectorIcon = def?.icon return ( - + + {connector.status === 'syncing' ? ( + + ) : ( + ConnectorIcon && + )} + {connector.status !== 'active' && connector.status !== 'syncing' && ( + + )} + + {def?.name || connector.connectorType} + ) })} @@ -1317,6 +1334,7 @@ export function KnowledgeBase({ connectors={connectors} isLoading={isLoadingConnectors} canEdit={userPermissions.canEdit} + className='mt-0' /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 55760bb818d..d2dab7eec57 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { ArrowLeft, ArrowLeftRight, Plus, Search } from 'lucide-react' +import { ArrowLeft, ArrowLeftRight, Info, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, @@ -218,7 +218,10 @@ export function AddConnectorModal({ return ( <> !isCreating && onOpenChange(val)}> - + {step === 'configure' && ( + + {field.description} + )} - + {hasCanonicalPair && canonicalId && ( - + {field.mode === 'basic' @@ -372,9 +384,6 @@ export function AddConnectorModal({ )} - {field.description && ( -

{field.description}

- )} {field.type === 'selector' && field.selectorKey ? ( ) : field.type === 'dropdown' && field.options ? ( ({ label: opt.label, value: opt.id, @@ -416,7 +424,6 @@ export function AddConnectorModal({ ) })} - {/* Tag definitions (opt-out) */} {connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
@@ -448,8 +455,10 @@ export function AddConnectorModal({ }) }} /> - {tagDef.displayName} - + + {tagDef.displayName} + + ({tagDef.fieldType})
@@ -457,7 +466,6 @@ export function AddConnectorModal({ )} - {/* Sync interval */}
-
- +
+ {config.name} - {config.description} + {config.description}
- + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index aab56688cad..ea39d97cfac 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -72,7 +72,7 @@ export function ConnectorSelectorField({ if (isLoading && isEnabled) { return ( -
+
Loading…
@@ -97,6 +97,7 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} + emptyMessage={`No ${field.title.toLowerCase()} found`} /> ) } @@ -117,6 +118,7 @@ export function ConnectorSelectorField({ : field.placeholder || `Select ${field.title.toLowerCase()}` } disabled={disabled || !credentialId || !depsResolved} + emptyMessage={`No ${field.title.toLowerCase()} found`} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index a505c8496ef..b388ff2b246 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { format, formatDistanceToNow, isPast } from 'date-fns' import { @@ -58,6 +58,7 @@ interface ConnectorsSectionProps { connectors: ConnectorData[] isLoading: boolean canEdit: boolean + className?: string } /** 5-minute cooldown after a manual sync trigger */ @@ -68,19 +69,24 @@ const STATUS_CONFIG = { syncing: { label: 'Syncing', variant: 'amber' as const }, error: { label: 'Error', variant: 'red' as const }, paused: { label: 'Paused', variant: 'gray' as const }, - disabled: { label: 'Disabled', variant: 'amber' as const }, + disabled: { label: 'Disabled', variant: 'orange' as const }, } as const +const CONNECTOR_ACTION_BUTTON_CLASSES = + 'size-7 rounded-lg p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]' + export function ConnectorsSection({ workspaceId, knowledgeBaseId, connectors, isLoading, canEdit, + className, }: ConnectorsSectionProps) { const { mutate: triggerSync } = useTriggerSync() const { mutate: updateConnector } = useUpdateConnector() const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector() + const deleteDocumentsId = useId() const [deleteTarget, setDeleteTarget] = useState(null) const [deleteDocuments, setDeleteDocuments] = useState(false) @@ -184,16 +190,16 @@ export function ConnectorsSection({ if (connectors.length === 0 && !canEdit && !isLoading) return null return ( -
+
{error &&

{error}

} {isLoading ? ( -
+
{Array.from({ length: 2 }).map((_, i) => ( -
+
- -
+ +
@@ -209,7 +215,7 @@ export function ConnectorsSection({ No connected sources yet. Connect an external source to automatically sync documents.

) : ( -
+
{connectors.map((connector) => ( Remove Connector - + This will disconnect the source and stop future syncs. Documents already synced will remain in the knowledge base unless you choose to delete them.
setDeleteDocuments(checked === true)} /> @@ -355,28 +361,35 @@ function ConnectorCard({ const syncLogs = detail?.syncLogs ?? [] return ( -
-
-
-
- {Icon && } +
+
+
+
+ {Icon && } {connector.status === 'disabled' && ( - + )}
-
-
- - {connectorDef?.name || connector.connectorType} +
+
+ + {connectorDef?.name || connector.connectorType} {(isSyncPending || connector.status === 'syncing') && ( )} - + {statusConfig.label}
-
+
{connector.lastSyncAt && ( Last sync: {format(new Date(connector.lastSyncAt), 'MMM d, h:mm a')} )} @@ -409,14 +422,14 @@ function ConnectorCard({
-
+
{canEdit && ( <> @@ -440,7 +450,11 @@ function ConnectorCard({ - @@ -451,7 +465,7 @@ function ConnectorCard({ @@ -486,11 +504,11 @@ function ConnectorCard({ @@ -500,13 +518,13 @@ function ConnectorCard({
{connector.status === 'disabled' && ( -
-
-
- +
+
+
+ Connector disabled after repeated sync failures
-

+

Syncing has been paused due to {connector.consecutiveFailures} consecutive failures. {serviceId ? ' Reconnect your account to resume syncing.' @@ -529,7 +547,8 @@ function ConnectorCard({ } setShowOAuthModal(true) }} - className='w-full px-2 py-1 font-medium text-caption' + size='sm' + className='w-full' > Reconnect @@ -539,10 +558,10 @@ function ConnectorCard({ )} {missingScopes.length > 0 && connector.status !== 'disabled' && ( -

-
-
- +
+
+
+ Additional permissions required
{canEdit && ( @@ -562,7 +581,8 @@ function ConnectorCard({ } setShowOAuthModal(true) }} - className='w-full px-2 py-1 font-medium text-caption' + size='sm' + className='w-full' > Update access @@ -572,7 +592,7 @@ function ConnectorCard({ )} {expanded && ( -
+
)} @@ -620,7 +640,7 @@ interface SyncHistoryProps { function SyncHistory({ logs, isLoading }: SyncHistoryProps) { if (isLoading) { return ( -
+
Loading sync history…
@@ -628,11 +648,15 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) { } if (logs.length === 0) { - return

No sync history yet.

+ return ( +

+ No sync history yet. +

+ ) } return ( -
+
{logs.map((log) => { const isError = log.status === 'error' || log.status === 'failed' const isRunning = log.status === 'running' || log.status === 'syncing' @@ -640,14 +664,14 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) { log.docsAdded + log.docsUpdated + log.docsDeleted + (log.docsFailed ?? 0) return ( -
+
{isRunning ? ( ) : isError ? ( ) : ( - + )}
@@ -661,14 +685,12 @@ function SyncHistory({ logs, isLoading }: SyncHistoryProps) { {totalChanges > 0 ? ( <> {log.docsAdded > 0 && ( - +{log.docsAdded} + +{log.docsAdded} )} {log.docsUpdated > 0 && ( <> {log.docsAdded > 0 && ' '} - - ~{log.docsUpdated} - + ~{log.docsUpdated} )} {log.docsDeleted > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index a15578145f5..46c55b6759a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowLeftRight, ExternalLink, RotateCcw } from 'lucide-react' +import { ArrowLeftRight, ExternalLink, Info, RotateCcw } from 'lucide-react' import { Button, ButtonGroup, @@ -295,7 +295,7 @@ export function EditConnectorModal({ Documents - +
- +
+ + {field.description && ( + + + + + {field.description} + + )} +
{hasCanonicalPair && canonicalId && ( - + {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'} @@ -406,9 +424,6 @@ function SettingsTab({ )}
- {field.description && ( -

{field.description}

- )} {field.type === 'selector' && field.selectorKey ? ( ) : field.type === 'dropdown' && field.options ? ( ({ label: opt.label, value: opt.id, @@ -499,46 +513,55 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) { if (isLoading) { return (
- - - + + + +
) } return ( -
+
setFilter(val as 'active' | 'excluded')}> Active ({counts.active}) Excluded ({counts.excluded}) -
+
{documents.length === 0 ? ( -

+

{filter === 'excluded' ? 'No excluded documents' : 'No documents yet'}

) : ( -
+
{documents.map((doc) => ( -
+
{doc.filename} {doc.sourceUrl && ( - - - + + + + + + + Open source document + )}