diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 405f59e4..d92d3b91 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -15,7 +15,7 @@ jobs: e2e-tests: name: ๐Ÿงช Run E2E Tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 steps: - name: ๐Ÿ“ฅ Checkout code diff --git a/app/api/[resource]/[resourceId]/members/route.ts b/app/api/[resource]/[resourceId]/members/route.ts index be04af47..e3fc4963 100644 --- a/app/api/[resource]/[resourceId]/members/route.ts +++ b/app/api/[resource]/[resourceId]/members/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { userService, type UserProfile } from '~/lib/services/userService'; -import { getDataSource } from '~/lib/services/datasourceService'; +import { type UserProfile, userService } from '~/lib/services/userService'; +import { getDataSource } from '~/lib/services/dataSourceService'; import { getEnvironment } from '~/lib/services/environmentService'; import { getWebsite } from '~/lib/services/websiteService'; import { getPermissionLevelDetails, type PermissionLevel } from '~/lib/services/permissionService'; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 042d7d0b..ab5c6b40 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -10,11 +10,9 @@ import { messageService } from '~/lib/services/messageService'; import { MESSAGE_ROLE } from '~/types/database'; import { createId } from '@paralleldrive/cuid2'; import { conversationService } from '~/lib/services/conversationService'; -import type { StarterPluginId } from '~/lib/plugins/types'; import type { FileMap } from '~/lib/stores/files'; import { getTelemetry } from '~/lib/telemetry/telemetry-manager'; import { TelemetryEventType } from '~/lib/telemetry/telemetry-types'; -import { prisma } from '~/lib/prisma'; import { LLMManager } from '~/lib/modules/llm/manager'; import { DataSourcePluginManager } from '~/lib/plugins/data-access/data-access-plugin-manager'; import { type UserProfile, userService } from '~/lib/services/userService'; @@ -23,6 +21,7 @@ import { getDatabaseSchema } from '~/lib/schema'; import { requireUserId } from '~/auth/session'; import { formatDbSchemaForLLM } from '~/lib/.server/llm/database-source'; import { AI_SDK_INVALID_KEY_ERROR } from '~/utils/constants'; +import { getDatabaseUrl } from '~/lib/services/dataSourceService'; const WORK_DIR = '/home/project'; @@ -201,8 +200,13 @@ async function chatAction(request: NextRequest) { }; dataStream.writeData(currentProgressAnnotation); - const dataSource = await conversationService.getConversationDataSource(conversationId); - const databaseSchema = await getDatabaseSchema(dataSource.id, userId); + const environmentDataSource = + await conversationService.getConversationEnvironmentDataSource(conversationId); + const databaseSchema = await getDatabaseSchema( + environmentDataSource.dataSourceId, + environmentDataSource.environmentId, + userId, + ); implementationPlan = await createImplementationPlan({ isFirstUserMessage: !!userMessageProperties.isFirstUserMessage, @@ -280,6 +284,7 @@ async function chatAction(request: NextRequest) { const userId = await requireUserId(request); const user = await userService.getUser(userId); + await trackChatPrompt(conversationId, currentModel, user, userMessageProperties.content); } catch (error) { logger.error('Failed to save prompt', error); @@ -325,7 +330,7 @@ async function chatAction(request: NextRequest) { options, files, promptId, - starterId: conversation?.starterId as StarterPluginId, + conversation: conversation!, contextOptimization, contextFiles: filteredFiles, summary, @@ -430,36 +435,33 @@ async function trackChatPrompt( userMessage: string, ): Promise { try { - const conversationWithDataSource = await prisma.conversation.findUnique({ - where: { id: conversationId }, - include: { - dataSource: { - select: { - connectionString: true, - }, - }, - }, - }); + const environmentDataSource = await conversationService.getConversationEnvironmentDataSource(conversationId); + const dataSourceUrl = await getDatabaseUrl( + user.id, + environmentDataSource.dataSourceId, + environmentDataSource.environmentId, + ); - if (conversationWithDataSource?.dataSource.connectionString) { - const pluginId = DataSourcePluginManager.getAccessorPluginId( - conversationWithDataSource.dataSource.connectionString, - ); - - const telemetry = await getTelemetry(); - await telemetry.trackTelemetryEvent( - { - eventType: TelemetryEventType.USER_CHAT_PROMPT, - properties: { - conversationId, - dataSourceType: pluginId, - llmModel, - userMessage, - }, - }, - user, - ); + if (!dataSourceUrl) { + logger.warn('No data source URL found for telemetry tracking'); + return; } + + const pluginId = DataSourcePluginManager.getAccessorPluginId(dataSourceUrl); + + const telemetry = await getTelemetry(); + await telemetry.trackTelemetryEvent( + { + eventType: TelemetryEventType.USER_CHAT_PROMPT, + properties: { + conversationId, + dataSourceType: pluginId, + llmModel, + userMessage, + }, + }, + user, + ); } catch (telemetryError) { logger.error('Failed to track telemetry event', telemetryError); } diff --git a/app/api/conversations/[conversationId]/route.ts b/app/api/conversations/[conversationId]/route.ts index e84cbc6e..7f043317 100644 --- a/app/api/conversations/[conversationId]/route.ts +++ b/app/api/conversations/[conversationId]/route.ts @@ -108,6 +108,8 @@ async function handleDelete(conversationId: string, userId: string) { const UPDATE_CONVERSATION_SCHEMA = z.object({ description: z.string().nullable().optional(), + environmentId: z.string().optional(), + dataSourceId: z.string().optional(), }); async function handlePatch(conversationId: string, userId: string, request: NextRequest) { @@ -115,11 +117,7 @@ async function handlePatch(conversationId: string, userId: string, request: Next const body = await request.json(); const updateData = UPDATE_CONVERSATION_SCHEMA.parse(body); - const updatedConversation = await conversationService.updateConversationDescription( - conversationId, - userId, - updateData, - ); + const updatedConversation = await conversationService.updateConversation(conversationId, userId, updateData); if (!updatedConversation) { return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); diff --git a/app/api/conversations/[conversationId]/snapshots/message/[messageId]/route.ts b/app/api/conversations/[conversationId]/snapshots/message/[messageId]/route.ts new file mode 100644 index 00000000..82029635 --- /dev/null +++ b/app/api/conversations/[conversationId]/snapshots/message/[messageId]/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { conversationService } from '~/lib/services/conversationService'; +import { StorageServiceFactory } from '~/lib/services/storage/storage-service-factory'; +import { snapshotService } from '~/lib/services/snapshotService'; +import { logger } from '~/utils/logger'; +import { requireUserId } from '~/auth/session'; + +// Zod schema for the request body +const UPDATE_SNAPSHOT_SCHEMA = z.object({ + fileMap: z.record( + z.string(), + z + .union([ + z.object({ + type: z.literal('file'), + content: z.string(), + isBinary: z.boolean(), + }), + z.object({ + type: z.literal('folder'), + }), + ]) + .optional(), + ), +}); + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ conversationId: string; messageId: string }> }, +) { + const { conversationId, messageId } = await params; + + const userId = await requireUserId(request); + + const conversation = await conversationService.getConversation(conversationId); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } + + // Check if the conversation belongs to the authenticated user + if (conversation.userId !== userId) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } + + try { + // Find the snapshot associated with this message ID + const snapshot = await snapshotService.getSnapshotByMessageId(messageId); + + if (!snapshot) { + logger.error(`Snapshot not found for message ${messageId}`); + return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 }); + } + + if (snapshot.conversationId !== conversationId) { + logger.error(`Snapshot ${snapshot.id} does not belong to conversation ${conversationId}`); + return NextResponse.json({ error: 'Snapshot does not belong to this conversation' }, { status: 403 }); + } + + const storageService = StorageServiceFactory.get(); + const data = await storageService.get(snapshot.storageKey); + const fileMap = JSON.parse(data.toString()); + + if (!fileMap) { + logger.error(`No snapshot files found for snapshot ${snapshot.id}`); + return NextResponse.json({ error: 'No snapshot files found' }, { status: 404 }); + } + + // Validate and parse the request body + const body = await request.json(); + const requestData = UPDATE_SNAPSHOT_SCHEMA.parse(body); + const updatedFileMap = { ...fileMap, ...requestData.fileMap }; + + const serializedData = Buffer.from(JSON.stringify(updatedFileMap, null, 2)); + await storageService.save(snapshot.storageKey, serializedData); + + return NextResponse.json({ + success: true, + snapshot: { + ...snapshot, + fileMap: updatedFileMap, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request body', details: error.errors }, { status: 400 }); + } + + logger.error('Failed to update snapshot:', error); + + return NextResponse.json({ error: 'Failed to update snapshot' }, { status: 500 }); + } +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts index c0f9c1d1..547f9666 100644 --- a/app/api/conversations/route.ts +++ b/app/api/conversations/route.ts @@ -16,6 +16,7 @@ export async function POST(request: NextRequest) { dataSourceId: string; description?: string; messages?: Message[]; + environmentId: string; }; if (!body?.dataSourceId) { @@ -27,6 +28,7 @@ export async function POST(request: NextRequest) { const conversation = await conversationService.createConversation( body.dataSourceId, + body.environmentId, userId, body.description, tx, diff --git a/app/api/data-sources/[id]/route.ts b/app/api/data-sources/[id]/route.ts index 21a1f966..e5b4125e 100644 --- a/app/api/data-sources/[id]/route.ts +++ b/app/api/data-sources/[id]/route.ts @@ -2,35 +2,47 @@ import { NextRequest, NextResponse } from 'next/server'; import { deleteDataSource, getConversationCount, - getDataSource, + getEnvironmentDataSource, updateDataSource, -} from '~/lib/services/datasourceService'; +} from '~/lib/services/dataSourceService'; import { requireUserId } from '~/auth/session'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); - const dataSource = await getDataSource(id); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } const conversationCount = await getConversationCount(id, userId); - return NextResponse.json({ success: true, dataSource, conversationCount }); + return NextResponse.json({ success: true, environmentDataSource, conversationCount }); } export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } - const dataSource = await getDataSource(id); + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } @@ -53,10 +65,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } - const dataSource = await getDataSource(id); + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } diff --git a/app/api/data-sources/[id]/url/route.ts b/app/api/data-sources/[id]/url/route.ts index aa99a309..24d1e418 100644 --- a/app/api/data-sources/[id]/url/route.ts +++ b/app/api/data-sources/[id]/url/route.ts @@ -1,11 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDatabaseUrl } from '~/lib/services/datasourceService'; +import { getDatabaseUrl } from '~/lib/services/dataSourceService'; import { requireUserId } from '~/auth/session'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; - const url = await getDatabaseUrl(userId, id); + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); - return NextResponse.json({ url, success: true }); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + try { + const url = await getDatabaseUrl(userId, id, environmentId); + return NextResponse.json({ url, success: true }); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to get database URL' }, + { status: 404 }, + ); + } } diff --git a/app/api/data-sources/example/route.ts b/app/api/data-sources/example/route.ts index 18d3f6ad..e800f843 100644 --- a/app/api/data-sources/example/route.ts +++ b/app/api/data-sources/example/route.ts @@ -1,11 +1,19 @@ import { prisma } from '~/lib/prisma'; import { NextRequest, NextResponse } from 'next/server'; import { requireUserId } from '~/auth/session'; +import { createSampleDataSource } from '~/lib/services/dataSourceService'; export async function POST(request: NextRequest) { const userId = await requireUserId(request); try { + const body = await request.json(); + const { environmentId } = body as { environmentId: string }; + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + const existingSampleDatabase = await prisma.dataSource.findFirst({ where: { createdById: userId, @@ -17,12 +25,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Sample database already exists' }, { status: 400 }); } - const dataSource = await prisma.dataSource.create({ - data: { - createdById: userId, - name: 'Sample Database', - connectionString: 'sqlite://sample.db', - }, + const dataSource = await createSampleDataSource({ + createdById: userId, + environmentId, }); return NextResponse.json({ success: true, dataSource }); diff --git a/app/api/data-sources/route.ts b/app/api/data-sources/route.ts index d5491a81..8f442703 100644 --- a/app/api/data-sources/route.ts +++ b/app/api/data-sources/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createDataSource, getDataSources } from '~/lib/services/datasourceService'; +import { createDataSource, getEnvironmentDataSources } from '~/lib/services/dataSourceService'; import { requireUserAbility } from '~/auth/session'; import { PermissionAction, PermissionResource } from '@prisma/client'; @@ -10,9 +10,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); } - const dataSources = await getDataSources(userAbility); + const environmentDataSources = await getEnvironmentDataSources(userAbility); - return NextResponse.json({ success: true, dataSources }); + return NextResponse.json({ success: true, environmentDataSources }); } export async function POST(request: NextRequest) { @@ -25,12 +25,18 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const connectionString = formData.get('connectionString') as string; const name = formData.get('name') as string; + const environmentId = formData.get('environmentId') as string; + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } try { const dataSource = await createDataSource({ name, - connectionString, createdById: userId, + environmentId, + connectionString, }); return NextResponse.json({ success: true, dataSource }); diff --git a/app/api/environments/route.ts b/app/api/environments/route.ts index 7168b5d5..14382d9e 100644 --- a/app/api/environments/route.ts +++ b/app/api/environments/route.ts @@ -4,6 +4,23 @@ import { organizationService } from '~/lib/services/organizationService'; import { requireUserAbility } from '~/auth/session'; import { PermissionAction, PermissionResource, Prisma } from '@prisma/client'; +export type CreateEnvironmentResponse = + | { + success: true; + environment?: { + id: string; + name: string; + description?: string; + organizationId: string; + createdAt: string; + updatedAt: string; + }; + } + | { + success: false; + error: string; + }; + export async function GET(request: NextRequest) { const { userAbility } = await requireUserAbility(request); diff --git a/app/api/format-query/route.ts b/app/api/format-query/route.ts deleted file mode 100644 index 1a1ebfcd..00000000 --- a/app/api/format-query/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { DataAccessor } from '@liblab/data-access/dataAccessor'; -import { getConnectionProtocol } from '@liblab/data-access/utils/connection'; -import { requireUserId } from '~/auth/session'; -import { prisma } from '~/lib/prisma'; - -const requestSchema = z.object({ - query: z.string(), - dataSourceId: z.string(), -}); - -export async function POST(request: NextRequest) { - const userId = await requireUserId(request); - - try { - const body = await request.json(); - const { query, dataSourceId } = requestSchema.parse(body); - - // Get the data source to determine the database type - const dataSource = await prisma.dataSource.findUniqueOrThrow({ - where: { id: dataSourceId, createdById: userId }, - }); - - const type = getConnectionProtocol(dataSource.connectionString); - const accessor = DataAccessor.getByDatabaseType(type); - - if (!accessor) { - return NextResponse.json({ error: 'Unsupported database type' }, { status: 400 }); - } - - const formattedQuery = accessor.formatQuery(query); - - return NextResponse.json({ formattedQuery }); - } catch (error) { - console.error('Error formatting query:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to format query' }, - { status: 500 }, - ); - } -} diff --git a/app/api/generate-query/route.ts b/app/api/generate-query/route.ts deleted file mode 100644 index 740ff680..00000000 --- a/app/api/generate-query/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getDatabaseSchema } from '~/lib/schema'; -import { NextRequest, NextResponse } from 'next/server'; -import { generateSqlQueries } from '~/lib/.server/llm/database-source'; -import { createScopedLogger } from '~/utils/logger'; -import { z } from 'zod'; -import { prisma } from '~/lib/prisma'; -import { requireUserId } from '~/auth/session'; - -const logger = createScopedLogger('generate-sql'); - -const requestSchema = z.object({ - prompt: z.string(), - existingQuery: z.string().optional(), - dataSourceId: z.string(), - suggestedDatabaseType: z.string().optional(), -}); - -export async function POST(request: NextRequest) { - const userId = await requireUserId(request); - - try { - const body = await request.json(); - const { prompt, existingQuery, dataSourceId } = requestSchema.parse(body); - const existingQueries = existingQuery ? [existingQuery] : []; - - const schema = await getDatabaseSchema(dataSourceId, userId); - - const dataSource = await prisma.dataSource.findUniqueOrThrow({ - where: { id: dataSourceId, createdById: userId }, - }); - - const queries = await generateSqlQueries({ - schema, - userPrompt: prompt, - connectionString: dataSource.connectionString, - existingQueries, - }); - - if (!queries || queries.length === 0) { - return NextResponse.json({ error: 'Failed to generate SQL query' }, { status: 500 }); - } - - return NextResponse.json(queries[0].query); - } catch (error) { - logger.error('Error generating SQL:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to generate SQL query' }, - { status: 500 }, - ); - } -} diff --git a/app/api/suggestions/route.ts b/app/api/suggestions/route.ts index 4a00876e..74cc5e83 100644 --- a/app/api/suggestions/route.ts +++ b/app/api/suggestions/route.ts @@ -1,26 +1,42 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDataSource } from '~/lib/services/datasourceService'; +import { getEnvironmentDataSource } from '~/lib/services/dataSourceService'; import { generateSchemaBasedSuggestions } from '~/lib/services/suggestionService'; import { SAMPLE_DATABASE_NAME } from '@liblab/data-access/accessors/sqlite'; import { logger } from '~/utils/logger'; +import { DataSourcePropertyType } from '@prisma/client'; +import { requireUserId } from '~/auth/session'; export async function POST(request: NextRequest) { try { - const { dataSourceId } = await request.json<{ dataSourceId: string }>(); + const userId = await requireUserId(request); + const { dataSourceId, environmentId } = await request.json<{ dataSourceId: string; environmentId: string }>(); if (!dataSourceId) { return NextResponse.json({ success: false, error: 'Data source ID is required' }, { status: 400 }); } - // Fetch the data source using the service - const dataSource = await getDataSource(dataSourceId); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + // Fetch the environment data source using the service + const environmentDataSource = await getEnvironmentDataSource(dataSourceId, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } - // Check if it's the sample database - const isSampleDatabase = dataSource.connectionString === `sqlite://${SAMPLE_DATABASE_NAME}`; + const dataSourceProperty = environmentDataSource.dataSourceProperties.find( + (dsp) => dsp.type === DataSourcePropertyType.CONNECTION_URL, + ); + + if (!dataSourceProperty) { + return NextResponse.json({ success: false, error: 'Data source property not found' }, { status: 404 }); + } + + const connectionUrl = dataSourceProperty.environmentVariable.value; + + const isSampleDatabase = connectionUrl === `sqlite://${SAMPLE_DATABASE_NAME}`; let suggestions: string[]; @@ -32,7 +48,17 @@ export async function POST(request: NextRequest) { ]; } else { // Generate schema-based suggestions for non-sample databases - suggestions = await generateSchemaBasedSuggestions(dataSource); + // Create a compatible dataSource object for the suggestion service + const dataSourceForSuggestions = { + id: environmentDataSource.dataSource.id, + name: environmentDataSource.dataSource.name, + connectionString: connectionUrl || '', + environmentId: environmentDataSource.environmentId, + environmentName: environmentDataSource.environment.name, + createdAt: environmentDataSource.dataSource.createdAt, + updatedAt: environmentDataSource.dataSource.updatedAt, + }; + suggestions = await generateSchemaBasedSuggestions(dataSourceForSuggestions); } return NextResponse.json({ diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index e359c058..cb5d4501 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -24,6 +24,7 @@ import { DeprecatedRole } from '@prisma/client'; import OrganizationTab from '~/components/@settings/tabs/organization/OrganizationTab'; import MembersTab from '~/components/@settings/tabs/members/MembersTab'; import RolesTab from '~/components/@settings/tabs/roles/RolesTab'; +import EnvironmentsTab from '~/components/@settings/tabs/environments'; const LAST_ACCESSED_TAB_KEY = 'control-panel-last-tab'; @@ -167,6 +168,8 @@ export const ControlPanel = () => { switch (tabId) { case 'data': return ; + case 'environments': + return ; case 'deployed-apps': return ; case 'github': diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts index 8cdb7ce3..cc240671 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.ts @@ -1,5 +1,5 @@ import type { TabType, TabVisibilityConfig } from './types'; -import { Building, Database, GitBranch, type LucideIcon, Rocket, Users, ShieldUser } from 'lucide-react'; +import { Building, Database, GitBranch, type LucideIcon, Rocket, Users, Server, ShieldUser } from 'lucide-react'; export const TAB_ICONS: Record = { data: Database, @@ -8,6 +8,7 @@ export const TAB_ICONS: Record = { organization: Building, members: Users, roles: ShieldUser, + environments: Server, }; export const TAB_LABELS: Record = { @@ -17,6 +18,7 @@ export const TAB_LABELS: Record = { organization: 'Organization', members: 'Members', roles: 'Roles', + environments: 'Environments', }; export const TAB_DESCRIPTIONS: Record = { @@ -26,12 +28,14 @@ export const TAB_DESCRIPTIONS: Record = { organization: 'Manage your organization', members: 'Manage your organization members', roles: 'Manage roles and permissions for organization members', + environments: 'Manage your environments', }; export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'data', visible: true, window: 'user', order: 0 }, - { id: 'github', visible: true, window: 'user', order: 1 }, - { id: 'deployed-apps', visible: true, window: 'user', order: 2 }, + { id: 'environments', visible: true, window: 'user', order: 1 }, + { id: 'github', visible: true, window: 'user', order: 2 }, + { id: 'deployed-apps', visible: true, window: 'user', order: 3 }, { id: 'organization', visible: true, window: 'admin', order: 0 }, { id: 'members', visible: true, window: 'admin', order: 1 }, diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index 43729c0a..9e73c62a 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,4 @@ -export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members' | 'roles'; +export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members' | 'roles' | 'environments'; export type WindowType = 'user' | 'admin'; @@ -31,4 +31,5 @@ export const TAB_LABELS: Record = { organization: 'Organization', members: 'Members', roles: 'Roles', + environments: 'Environments', }; diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx index 421778b3..2750a527 100644 --- a/app/components/@settings/tabs/data/DataTab.tsx +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -6,21 +6,13 @@ import AddDataSourceForm from './forms/AddDataSourceForm'; import EditDataSourceForm from './forms/EditDataSourceForm'; import { classNames } from '~/utils/classNames'; import { toast } from 'sonner'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { type EnvironmentDataSource, useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { settingsPanelStore, useSettingsStore } from '~/lib/stores/settings'; import { useStore } from '@nanostores/react'; -export interface DataSource { - id: string; - name: string; - connectionString: string; - createdAt: string; - updatedAt: string; -} - -interface DataSourcesResponse { +interface EnvironmentDataSourcesResponse { success: boolean; - dataSources: DataSource[]; + environmentDataSources: EnvironmentDataSource[]; } export interface TestConnectionResponse { @@ -33,10 +25,12 @@ export default function DataTab() { const [showAddFormLocal, setShowAddFormLocal] = useState(showAddForm); const [showEditForm, setShowEditForm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [selectedDataSource, setSelectedDataSource] = useState(null); + const [selectedEnvironmentDataSource, setSelectedEnvironmentDataSource] = useState( + null, + ); const [isSubmitting, setIsSubmitting] = useState(false); const [conversationCount, setConversationCount] = useState(0); - const { dataSources, setDataSources } = useDataSourcesStore(); + const { environmentDataSources, setEnvironmentDataSources } = useEnvironmentDataSourcesStore(); const { selectedTab } = useSettingsStore(); // Update local state when store changes @@ -56,10 +50,10 @@ export default function DataTab() { const loadDataSources = async () => { try { const response = await fetch('/api/data-sources'); - const data = (await response.json()) as DataSourcesResponse; + const data = (await response.json()) as EnvironmentDataSourcesResponse; if (data.success) { - setDataSources(data.dataSources); + setEnvironmentDataSources(data.environmentDataSources); } } catch (error) { console.error('Failed to load data sources:', error); @@ -67,16 +61,19 @@ export default function DataTab() { }; loadDataSources(); - }, [setDataSources]); + }, [setEnvironmentDataSources]); const handleDelete = async () => { - if (!selectedDataSource) { + if (!selectedEnvironmentDataSource) { return; } - const response = await fetch(`/api/data-sources/${selectedDataSource.id}`, { - method: 'DELETE', - }); + const response = await fetch( + `/api/data-sources/${selectedEnvironmentDataSource.dataSourceId}?environmentId=${selectedEnvironmentDataSource.environmentId}`, + { + method: 'DELETE', + }, + ); const data = (await response.json()) as { success: boolean; error?: string }; @@ -85,33 +82,35 @@ export default function DataTab() { // Reload data sources const reloadResponse = await fetch('/api/data-sources'); - const reloadData = (await reloadResponse.json()) as DataSourcesResponse; + const reloadData = (await reloadResponse.json()) as EnvironmentDataSourcesResponse; if (reloadData.success) { - setDataSources(reloadData.dataSources); + setEnvironmentDataSources(reloadData.environmentDataSources); } setShowDeleteConfirm(false); setShowEditForm(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); } else { toast.error(data.error || 'Failed to delete data source'); } }; - const handleEdit = (dataSource: DataSource) => { - setSelectedDataSource(dataSource); + const handleEdit = (environmentDataSource: EnvironmentDataSource) => { + setSelectedEnvironmentDataSource(environmentDataSource); setShowEditForm(true); setShowAddFormLocal(false); }; const handleDeleteClick = async () => { - if (!selectedDataSource) { + if (!selectedEnvironmentDataSource) { return; } try { - const response = await fetch(`/api/data-sources/${selectedDataSource.id}`); + const response = await fetch( + `/api/data-sources/${selectedEnvironmentDataSource.dataSourceId}?environmentId=${selectedEnvironmentDataSource.environmentId}`, + ); const data = await response.json<{ success: boolean; conversationCount?: number }>(); if (data.success) { @@ -131,13 +130,13 @@ export default function DataTab() { const handleBack = () => { setShowEditForm(false); setShowAddFormLocal(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); }; const handleAdd = () => { setShowAddFormLocal(true); setShowEditForm(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); }; return ( @@ -191,10 +190,10 @@ export default function DataTab() { reloadResponse .then((response) => response.json()) .then((data: unknown) => { - const typedData = data as DataSourcesResponse; + const typedData = data as EnvironmentDataSourcesResponse; if (typedData.success) { - setDataSources(typedData.dataSources); + setEnvironmentDataSources(typedData.environmentDataSources); } }) .catch((error) => console.error('Failed to reload data sources after add:', error)); @@ -204,7 +203,7 @@ export default function DataTab() { )} - {showEditForm && selectedDataSource && ( + {showEditForm && selectedEnvironmentDataSource && (
@@ -225,7 +224,7 @@ export default function DataTab() {
{ @@ -234,10 +233,10 @@ export default function DataTab() { reloadResponse .then((response) => response.json()) .then((data: unknown) => { - const typedData = data as DataSourcesResponse; + const typedData = data as EnvironmentDataSourcesResponse; if (typedData.success) { - setDataSources(typedData.dataSources); + setEnvironmentDataSources(typedData.environmentDataSources); } }) .catch((error) => console.error('Failed to reload data sources after edit:', error)); @@ -250,7 +249,7 @@ export default function DataTab() { {!showEditForm && !showAddFormLocal && (
- {dataSources.length === 0 ? ( + {environmentDataSources.length === 0 ? (

No Data Sources

@@ -259,9 +258,9 @@ export default function DataTab() {

) : ( - dataSources.map((dataSource) => ( + environmentDataSources.map((environmentDataSource) => ( handleEdit(dataSource)} + onClick={() => handleEdit(environmentDataSource)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { - handleEdit(dataSource); + handleEdit(environmentDataSource); } }} className="w-full flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-900 transition-all duration-200 cursor-pointer" >
-

{dataSource.name}

+

+ {environmentDataSource.dataSource.name} +

@@ -309,8 +310,8 @@ export default function DataTab() {

- Are you sure you want to delete the data source "{selectedDataSource?.name}"? This will remove all - associated data and cannot be undone. + Are you sure you want to delete the data source "{selectedEnvironmentDataSource?.dataSource.name}"? + This will This will remove all associated data and cannot be undone.

{conversationCount > 0 && ( diff --git a/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx b/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx index d1e25d4e..ee67d7ea 100644 --- a/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx +++ b/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx @@ -1,5 +1,5 @@ import { classNames } from '~/utils/classNames'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { CheckCircle, Loader2, Plug, Save, XCircle } from 'lucide-react'; import type { TestConnectionResponse } from '~/components/@settings/tabs/data/DataTab'; @@ -22,6 +22,24 @@ interface DataSourceResponse { }; } +interface Environment { + id: string; + name: string; + description?: string; +} + +interface EnvironmentOption { + label: string; + value: string; + description?: string; +} + +interface EnvironmentsResponse { + success: boolean; + environments: Environment[]; + error?: string; +} + const testConnectionResponseSchema = z.object({ success: z.boolean(), message: z.string(), @@ -33,15 +51,53 @@ interface AddDataSourceFormProps { onSuccess: () => void; } +// TODO: @skos update the form to use EnvironmentDataSource export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuccess }: AddDataSourceFormProps) { const [dbType, setDbType] = useState(DEFAULT_DATA_SOURCES[0]); const [dbName, setDbName] = useState(''); const [connStr, setConnStr] = useState(''); + const [selectedEnvironment, setSelectedEnvironment] = useState(null); + const [environmentOptions, setEnvironmentOptions] = useState([]); + const [isLoadingEnvironments, setIsLoadingEnvironments] = useState(true); const [isTestingConnection, setIsTestingConnection] = useState(false); const [testResult, setTestResult] = useState(null); const [error, setError] = useState(null); const { availableDataSourceOptions } = useDataSourceTypesPlugin(); + // Fetch environments on component mount + useEffect(() => { + const fetchEnvironments = async () => { + try { + const response = await fetch('/api/environments'); + const result: EnvironmentsResponse = await response.json(); + + if (result.success) { + // Transform environments to options + const options: EnvironmentOption[] = result.environments.map((env) => ({ + label: env.name, + value: env.id, + description: env.description, + })); + setEnvironmentOptions(options); + + // Auto-select first environment if available + if (options.length > 0) { + setSelectedEnvironment(options[0]); + } + } else { + setError(result.error || 'Failed to fetch environments'); + } + } catch (error) { + setError('Failed to fetch environments'); + console.error('Error fetching environments:', error); + } finally { + setIsLoadingEnvironments(false); + } + }; + + fetchEnvironments(); + }, []); + const handleTestConnection = async () => { setIsTestingConnection(true); setTestResult(null); @@ -54,6 +110,11 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc return; } + if (!selectedEnvironment) { + setError('Please select an environment'); + return; + } + const formData = new FormData(); formData.append('connectionString', connStr); @@ -97,8 +158,19 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc setIsSubmitting(true); try { + if (!selectedEnvironment) { + setError('Please select an environment'); + setTestResult(null); + + return; + } + const response = await fetch('/api/data-sources/example', { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ environmentId: selectedEnvironment.value }), }); const result = (await response.json()) as DataSourceResponse; @@ -126,6 +198,13 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc return; } + if (!selectedEnvironment) { + setError('Please select an environment'); + setTestResult(null); + + return; + } + if (!dbName) { setError('Please enter the data source name'); setTestResult(null); @@ -148,6 +227,7 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc const formData = new FormData(); formData.append('name', dbName); formData.append('connectionString', connStr); + formData.append('environmentId', selectedEnvironment.value); const response = await fetch('/api/data-sources', { method: 'POST', @@ -203,6 +283,27 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc
+
+ + { + setSelectedEnvironment(value); + setError(null); + setTestResult(null); + }} + options={environmentOptions} + placeholder={isLoadingEnvironments ? 'Loading environments...' : 'Select environment'} + isDisabled={isLoadingEnvironments} + width="100%" + minWidth="100%" + isSearchable={false} + /> + {selectedEnvironment?.description && ( +
{selectedEnvironment.description}
+ )} +
+ {dbType.value !== SAMPLE_DATABASE && ( <>
@@ -290,7 +391,7 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc e.preventDefault(); await handleTestConnection(); }} - disabled={isTestingConnection || isSubmitting || !connStr} + disabled={isTestingConnection || isSubmitting || !connStr || !selectedEnvironment} className={classNames( 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors', 'bg-depth-1 bg-depth-1/50 ', @@ -316,7 +417,7 @@ export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuc
+
+ + { + setSelectedEnvironment(value); + setError(null); + setTestResult(null); + }} + options={environmentOptions} + placeholder={isLoadingEnvironments ? 'Loading environments...' : 'Select environment'} + isDisabled={isLoadingEnvironments} + width="100%" + minWidth="100%" + isSearchable={false} + /> + {selectedEnvironment?.description && ( +
{selectedEnvironment.description}
+ )} +
+ {dbType.value !== SAMPLE_DATABASE && ( <>
@@ -315,7 +426,7 @@ export default function EditDataSourceForm({ e.preventDefault(); await handleTestConnection(); }} - disabled={isTestingConnection || isSubmitting || !connStr} + disabled={isTestingConnection || isSubmitting || !connStr || !selectedEnvironment} className={classNames( 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors', 'bg-blue-500 hover:bg-blue-600', @@ -355,7 +466,9 @@ export default function EditDataSourceForm({ +
+ )} + + {showAddFormLocal && ( +
+
+
+ +
+

Create Environment

+

Add a new environment

+
+
+
+ { + // Reload environments + const reloadResponse = fetch('/api/environments'); + reloadResponse + .then((response) => response.json()) + .then((data: unknown) => { + const typedData = data as EnvironmentsResponse; + + if (typedData.success) { + setEnvironments(typedData.environments); + } + }) + .catch((error) => console.error('Failed to reload environments after add:', error)); + handleBack(); + }} + /> +
+ )} + + {showEditForm && selectedEnvironment && ( +
+
+
+ +
+

Edit Environment

+

Modify your environment settings

+
+
+
+ { + // Reload environments + const reloadResponse = fetch('/api/environments'); + reloadResponse + .then((response) => response.json()) + .then((data: unknown) => { + const typedData = data as EnvironmentsResponse; + + if (typedData.success) { + setEnvironments(typedData.environments); + } + }) + .catch((error) => console.error('Failed to reload environments after edit:', error)); + handleBack(); + }} + onDelete={() => handleDeleteClick(selectedEnvironment)} + /> +
+ )} + + {!showEditForm && !showAddFormLocal && ( +
+ {environments.length === 0 ? ( +
+ +

No Environments

+

+ Get started by adding your first environment. +

+
+ ) : ( + environments.map((environment) => ( + +
+
handleEdit(environment)} + > + +
+

{environment.name}

+ {environment.description && ( +

{environment.description}

+ )} +
+
+
+ + +
+
+
+ )) + )} +
+ )} + + + +
+
+
+
+
+ +
+
+ +

This action cannot be undone

+
+
+
+ +
+

+ Are you sure you want to delete the environment "{selectedEnvironment?.name}"? This will remove all + associated data and cannot be undone. +

+ + {selectedEnvironment?.dataSources && selectedEnvironment.dataSources.length > 0 && ( +
+
+ +
+

+ Warning: This environment has {selectedEnvironment.dataSources.length} data source + {selectedEnvironment.dataSources.length === 1 ? '' : 's'}! +

+

+ All data sources and associated conversations will be permanently deleted. +

+
+
+
+ )} +
+ +
+ + + + +
+
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx b/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx new file mode 100644 index 00000000..b9e61659 --- /dev/null +++ b/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import type { CreateEnvironmentResponse } from '~/api/environments/route'; + +interface AddEnvironmentFormProps { + isSubmitting: boolean; + setIsSubmitting: (value: boolean) => void; + onSuccess: () => void; +} + +export default function AddEnvironmentForm({ isSubmitting, setIsSubmitting, onSuccess }: AddEnvironmentFormProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error('Environment name is required'); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch('/api/environments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim() || undefined, + }), + }); + + const data = await response.json(); + + if (data.success) { + toast.success('Environment created successfully'); + onSuccess(); + } else { + toast.error(data.error || 'Failed to create environment'); + } + } catch (error) { + console.error('Failed to create environment:', error); + toast.error('Failed to create environment'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+
+ + setName(e.target.value)} + className={classNames( + 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg', + 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white', + 'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent', + 'placeholder-gray-500 dark:placeholder-gray-400', + )} + placeholder="e.g., Development, Staging, Production" + disabled={isSubmitting} + required + /> +
+ +
+ +