diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 90bfc194c..239e786ac 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -204,6 +204,7 @@ export enum UseCaseType { UPDATE_DASHBOARD_WIDGET = 'UPDATE_DASHBOARD_WIDGET', DELETE_DASHBOARD_WIDGET = 'DELETE_DASHBOARD_WIDGET', GENERATE_WIDGET_WITH_AI = 'GENERATE_WIDGET_WITH_AI', + GENERATE_TABLE_DASHBOARD_WITH_AI = 'GENERATE_TABLE_DASHBOARD_WITH_AI', IS_CONFIGURED = 'IS_CONFIGURED', CREATE_INITIAL_USER = 'CREATE_INITIAL_USER', diff --git a/backend/src/entities/visualizations/panel-position/data-structures/generate-table-dashboard-with-ai.ds.ts b/backend/src/entities/visualizations/panel-position/data-structures/generate-table-dashboard-with-ai.ds.ts new file mode 100644 index 000000000..f825f2d88 --- /dev/null +++ b/backend/src/entities/visualizations/panel-position/data-structures/generate-table-dashboard-with-ai.ds.ts @@ -0,0 +1,8 @@ +export class GenerateTableDashboardWithAiDs { + connectionId: string; + masterPassword: string; + userId: string; + table_name: string; + max_panels?: number; + dashboard_name?: string; +} diff --git a/backend/src/entities/visualizations/panel-position/dto/generate-table-dashboard-with-ai.dto.ts b/backend/src/entities/visualizations/panel-position/dto/generate-table-dashboard-with-ai.dto.ts new file mode 100644 index 000000000..cae9308c5 --- /dev/null +++ b/backend/src/entities/visualizations/panel-position/dto/generate-table-dashboard-with-ai.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class GenerateTableDashboardWithAiDto { + @ApiPropertyOptional({ + type: Number, + description: 'Maximum number of panels to generate (1-10)', + default: 6, + minimum: 1, + maximum: 10, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + max_panels?: number; + + @ApiPropertyOptional({ + type: String, + description: 'Optional name for the dashboard. If not provided, AI will generate one.', + }) + @IsOptional() + @IsString() + @MaxLength(255) + dashboard_name?: string; +} diff --git a/backend/src/entities/visualizations/panel-position/dto/generated-panel-with-position.dto.ts b/backend/src/entities/visualizations/panel-position/dto/generated-panel-with-position.dto.ts index a016c1dcc..42dd10c4e 100644 --- a/backend/src/entities/visualizations/panel-position/dto/generated-panel-with-position.dto.ts +++ b/backend/src/entities/visualizations/panel-position/dto/generated-panel-with-position.dto.ts @@ -8,14 +8,14 @@ export class GeneratedPanelPositionDto { @ApiProperty({ description: 'Position Y in grid' }) position_y: number; - @ApiProperty({ description: 'Widget width in grid units' }) + @ApiProperty({ description: 'Panel width in grid units' }) width: number; - @ApiProperty({ description: 'Widget height in grid units' }) + @ApiProperty({ description: 'Panel height in grid units' }) height: number; - @ApiProperty({ description: 'Dashboard ID' }) - dashboard_id: string; + @ApiProperty({ description: 'Dashboard ID', nullable: true }) + dashboard_id: string | null; } export class GeneratedPanelWithPositionDto { @@ -25,14 +25,14 @@ export class GeneratedPanelWithPositionDto { @ApiPropertyOptional({ description: 'Panel description' }) description: string | null; - @ApiProperty({ description: 'Widget type', enum: DashboardWidgetTypeEnum }) - widget_type: DashboardWidgetTypeEnum; + @ApiProperty({ description: 'Panel type', enum: DashboardWidgetTypeEnum }) + panel_type: DashboardWidgetTypeEnum; - @ApiPropertyOptional({ description: 'Chart type for chart widgets' }) + @ApiPropertyOptional({ description: 'Chart type for chart panels' }) chart_type: string | null; @ApiPropertyOptional({ description: 'Visualization options' }) - widget_options: Record | null; + panel_options: Record | null; @ApiProperty({ description: 'AI-generated SQL query text' }) query_text: string; diff --git a/backend/src/entities/visualizations/panel-position/panel-position.controller.ts b/backend/src/entities/visualizations/panel-position/panel-position.controller.ts index c46fe16fb..6b111c29f 100644 --- a/backend/src/entities/visualizations/panel-position/panel-position.controller.ts +++ b/backend/src/entities/visualizations/panel-position/panel-position.controller.ts @@ -15,7 +15,7 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, import { UseCaseType } from '../../../common/data-injection.tokens.js'; import { MasterPassword } from '../../../decorators/master-password.decorator.js'; import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js'; -import { Timeout } from '../../../decorators/timeout.decorator.js'; +import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator.js'; import { UserId } from '../../../decorators/user-id.decorator.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js'; @@ -23,16 +23,19 @@ import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; import { CreatePanelPositionDs } from './data-structures/create-panel-position.ds.js'; import { DeletePanelPositionDs } from './data-structures/delete-panel-position.ds.js'; import { GeneratePanelPositionWithAiDs } from './data-structures/generate-panel-position-with-ai.ds.js'; +import { GenerateTableDashboardWithAiDs } from './data-structures/generate-table-dashboard-with-ai.ds.js'; import { UpdatePanelPositionDs } from './data-structures/update-panel-position.ds.js'; import { CreatePanelPositionDto } from './dto/create-panel-position.dto.js'; import { FoundPanelPositionDto } from './dto/found-panel-position.dto.js'; import { GeneratedPanelWithPositionDto } from './dto/generated-panel-with-position.dto.js'; import { GeneratePanelPositionWithAiDto } from './dto/generate-panel-position-with-ai.dto.js'; +import { GenerateTableDashboardWithAiDto } from './dto/generate-table-dashboard-with-ai.dto.js'; import { UpdatePanelPositionDto } from './dto/update-panel-position.dto.js'; import { ICreatePanelPositionWidget, IDeletePanelPosition, IGeneratePanelPositionWithAi, + IGenerateTableDashboardWithAi, IUpdatePanelPosition, } from './use-cases/panel-position-use-cases.interface.js'; @@ -52,6 +55,8 @@ export class DashboardWidgetController { private readonly deleteDashboardWidgetUseCase: IDeletePanelPosition, @Inject(UseCaseType.GENERATE_WIDGET_WITH_AI) private readonly generateWidgetWithAiUseCase: IGeneratePanelPositionWithAi, + @Inject(UseCaseType.GENERATE_TABLE_DASHBOARD_WITH_AI) + private readonly generateTableDashboardWithAiUseCase: IGenerateTableDashboardWithAi, ) {} @ApiOperation({ summary: 'Create a new widget in a dashboard' }) @@ -150,19 +155,19 @@ export class DashboardWidgetController { } @ApiOperation({ - summary: 'Generate a widget using AI', + summary: 'Generate a panel using AI', description: - 'Creates a new widget with SQL query and chart configuration generated by AI based on natural language description', + 'Creates a new panel with SQL query and chart configuration generated by AI based on natural language description', }) @ApiResponse({ status: 201, - description: 'Widget configuration generated by AI (not yet saved).', + description: 'Panel configuration generated by AI (not yet saved).', type: GeneratedPanelWithPositionDto, }) @ApiBody({ type: GeneratePanelPositionWithAiDto }) @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the widget for' }) + @ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the panel for' }) @UseGuards(ConnectionEditGuard) @Post('/dashboard/:dashboardId/widget/generate/:connectionId') async generateWidgetWithAi( @@ -184,4 +189,37 @@ export class DashboardWidgetController { }; return await this.generateWidgetWithAiUseCase.execute(inputData, InTransactionEnum.OFF); } + + @ApiOperation({ + summary: 'Generate a full table dashboard using AI', + description: + 'Analyzes a table structure and auto-generates multiple panel configurations for a dashboard using AI', + }) + @ApiResponse({ + status: 201, + description: 'Dashboard with panels generated by AI and saved to database.', + }) + @ApiBody({ type: GenerateTableDashboardWithAiDto }) + @ApiParam({ name: 'connectionId', required: true }) + @ApiQuery({ name: 'tableName', required: true, description: 'The table name to generate the dashboard for' }) + @UseGuards(ConnectionEditGuard) + @Timeout(TimeoutDefaults.AI) + @Post('/dashboard/generate-table-dashboard/:connectionId') + async generateTableDashboardWithAi( + @SlugUuid('connectionId') connectionId: string, + @Query('tableName') tableName: string, + @MasterPassword() masterPwd: string, + @UserId() userId: string, + @Body() generateDto: GenerateTableDashboardWithAiDto, + ): Promise<{ success: boolean }> { + const inputData: GenerateTableDashboardWithAiDs = { + connectionId, + masterPassword: masterPwd, + userId, + table_name: tableName, + max_panels: generateDto.max_panels, + dashboard_name: generateDto.dashboard_name, + }; + return await this.generateTableDashboardWithAiUseCase.execute(inputData, InTransactionEnum.ON); + } } diff --git a/backend/src/entities/visualizations/panel-position/panel-position.module.ts b/backend/src/entities/visualizations/panel-position/panel-position.module.ts index 6aff8301b..9abb760a0 100644 --- a/backend/src/entities/visualizations/panel-position/panel-position.module.ts +++ b/backend/src/entities/visualizations/panel-position/panel-position.module.ts @@ -10,6 +10,7 @@ import { DashboardWidgetController } from './panel-position.controller.js'; import { CreatePanelPositionUseCase } from './use-cases/create-panel-position.use.case.js'; import { DeletePanelPositionUseCase } from './use-cases/delete-panel-position.use.case.js'; import { GeneratePanelPositionWithAiUseCase } from './use-cases/generate-panel-position-with-ai.use.case.js'; +import { GenerateTableDashboardWithAiUseCase } from './use-cases/generate-table-dashboard-with-ai.use.case.js'; import { UpdateDashboardWidgetUseCase } from './use-cases/update-panel-position.use.case.js'; @Module({ @@ -35,6 +36,10 @@ import { UpdateDashboardWidgetUseCase } from './use-cases/update-panel-position. provide: UseCaseType.GENERATE_WIDGET_WITH_AI, useClass: GeneratePanelPositionWithAiUseCase, }, + { + provide: UseCaseType.GENERATE_TABLE_DASHBOARD_WITH_AI, + useClass: GenerateTableDashboardWithAiUseCase, + }, ], controllers: [DashboardWidgetController], exports: [], @@ -46,6 +51,7 @@ export class PanelPositionModule { .forRoutes( { path: '/dashboard/:dashboardId/widget/:connectionId', method: RequestMethod.POST }, { path: '/dashboard/:dashboardId/widget/generate/:connectionId', method: RequestMethod.POST }, + { path: '/dashboard/generate-table-dashboard/:connectionId', method: RequestMethod.POST }, { path: '/dashboard/:dashboardId/widget/:widgetId/:connectionId', method: RequestMethod.PUT }, { path: '/dashboard/:dashboardId/widget/:widgetId/:connectionId', method: RequestMethod.DELETE }, ); diff --git a/backend/src/entities/visualizations/panel-position/use-cases/generate-panel-position-with-ai.use.case.ts b/backend/src/entities/visualizations/panel-position/use-cases/generate-panel-position-with-ai.use.case.ts index 929a79467..9c8dd6698 100644 --- a/backend/src/entities/visualizations/panel-position/use-cases/generate-panel-position-with-ai.use.case.ts +++ b/backend/src/entities/visualizations/panel-position/use-cases/generate-panel-position-with-ai.use.case.ts @@ -14,13 +14,13 @@ import { GeneratedPanelWithPositionDto } from '../dto/generated-panel-with-posit import { IGeneratePanelPositionWithAi } from './panel-position-use-cases.interface.js'; import { validateQuerySafety } from '../../panel/utils/check-query-is-safe.util.js'; -interface AIGeneratedWidgetResponse { +interface AIGeneratedPanelResponse { name: string; description: string; query_text: string; - widget_type: 'chart' | 'table' | 'counter' | 'text'; + panel_type: 'chart' | 'table' | 'counter' | 'text'; chart_type?: 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; - widget_options?: { + panel_options?: { label_column?: string; value_column?: string; series?: Array<{ @@ -129,27 +129,27 @@ export class GeneratePanelPositionWithAiUseCase temperature: 0.3, }); - const generatedWidget = this.parseAIResponse(aiResponse); + const generatedPanel = this.parseAIResponse(aiResponse); - validateQuerySafety(generatedWidget.query_text, foundConnection.type as ConnectionTypesEnum); + validateQuerySafety(generatedPanel.query_text, foundConnection.type as ConnectionTypesEnum); - const refinedWidget = await this.validateAndRefineQueryWithExplain( + const refinedPanel = await this.validateAndRefineQueryWithExplain( dao, - generatedWidget, + generatedPanel, tableInfo, foundConnection.type as ConnectionTypesEnum, chart_description, ); return { - name: name || refinedWidget.name, - description: refinedWidget.description || null, - widget_type: this.mapWidgetType(refinedWidget.widget_type), - chart_type: refinedWidget.chart_type || null, - widget_options: refinedWidget.widget_options - ? (refinedWidget.widget_options as unknown as Record) + name: name || refinedPanel.name, + description: refinedPanel.description || null, + panel_type: this.mapPanelType(refinedPanel.panel_type), + chart_type: refinedPanel.chart_type || null, + panel_options: refinedPanel.panel_options + ? (refinedPanel.panel_options as unknown as Record) : null, - query_text: refinedWidget.query_text, + query_text: refinedPanel.query_text, connection_id: connectionId, panel_position: { position_x: position_x ?? 0, @@ -183,9 +183,9 @@ Generate a JSON response with the following structure: "name": "Short descriptive name for the chart (max 50 chars)", "description": "Brief description of what the chart shows", "query_text": "SELECT query that returns data for the chart. The query should return columns that can be used for labels and values. Use appropriate aggregations (COUNT, SUM, AVG, etc.) and GROUP BY clauses as needed. Always use the table name '${tableInfo.table_name}'.", - "widget_type": "chart" | "table" | "counter" | "text", + "panel_type": "chart" | "table" | "counter" | "text", "chart_type": "bar" | "line" | "pie" | "doughnut" | "polarArea", - "widget_options": { + "panel_options": { "label_column": "column name for labels/categories (x-axis)", "value_column": "column name for values (y-axis) - use this for single series", "series": [ @@ -218,19 +218,19 @@ IMPORTANT GUIDELINES: - pie/doughnut: parts of a whole (percentages) - polarArea: similar to pie but with equal angles 5. Use meaningful colors from this palette: #3366CC, #DC3912, #FF9900, #109618, #990099, #0099C6, #DD4477, #66AA00 -6. Set widget_options.label_column to the column that should be used for labels +6. Set panel_options.label_column to the column that should be used for labels 7. For single value series, use value_column directly 8. For multiple series, use the series array Respond ONLY with the JSON object, no additional text or explanation.`; } - private parseAIResponse(aiResponse: string): AIGeneratedWidgetResponse { + private parseAIResponse(aiResponse: string): AIGeneratedPanelResponse { try { const cleanedResponse = cleanAIJsonResponse(aiResponse); - const parsed = JSON.parse(cleanedResponse) as AIGeneratedWidgetResponse; + const parsed = JSON.parse(cleanedResponse) as AIGeneratedPanelResponse; - if (!parsed.name || !parsed.query_text || !parsed.widget_type) { + if (!parsed.name || !parsed.query_text || !parsed.panel_type) { throw new Error('Missing required fields in AI response'); } @@ -239,23 +239,23 @@ Respond ONLY with the JSON object, no additional text or explanation.`; console.error('Error parsing AI response:', error.message); console.error('AI Response:', aiResponse); throw new BadRequestException( - 'Failed to generate widget configuration from AI. Please try again with a different description.', + 'Failed to generate panel configuration from AI. Please try again with a different description.', ); } } private async validateAndRefineQueryWithExplain( dao: IDataAccessObject | IDataAccessObjectAgent, - generatedWidget: AIGeneratedWidgetResponse, + generatedPanel: AIGeneratedPanelResponse, tableInfo: TableInfo, connectionType: ConnectionTypesEnum, chartDescription: string, - ): Promise { + ): Promise { if (!EXPLAIN_SUPPORTED_TYPES.has(connectionType)) { - return generatedWidget; + return generatedPanel; } - let currentQuery = generatedWidget.query_text; + let currentQuery = generatedPanel.query_text; for (let iteration = 0; iteration < MAX_FEEDBACK_ITERATIONS; iteration++) { const explainResult = await this.runExplainQuery(dao, currentQuery, tableInfo.table_name); @@ -290,7 +290,7 @@ Respond ONLY with the JSON object, no additional text or explanation.`; } } - return { ...generatedWidget, query_text: currentQuery }; + return { ...generatedPanel, query_text: currentQuery }; } private async runExplainQuery( @@ -345,18 +345,31 @@ IMPORTANT: } private cleanQueryResponse(aiResponse: string): string { - return aiResponse + const cleaned = aiResponse .trim() .replace(/^```[a-zA-Z]*\n?/, '') .replace(/```\s*$/, '') .trim(); + + if (cleaned.startsWith('{')) { + try { + const parsed = JSON.parse(cleaned); + if (parsed.query_text) { + return parsed.query_text; + } + } catch { + // Not valid JSON, return as-is + } + } + + return cleaned; } private normalizeWhitespace(query: string): string { return query.replace(/\s+/g, ' ').trim(); } - private mapWidgetType(type: string): DashboardWidgetTypeEnum { + private mapPanelType(type: string): DashboardWidgetTypeEnum { switch (type) { case 'chart': return DashboardWidgetTypeEnum.Chart; diff --git a/backend/src/entities/visualizations/panel-position/use-cases/generate-table-dashboard-with-ai.use.case.ts b/backend/src/entities/visualizations/panel-position/use-cases/generate-table-dashboard-with-ai.use.case.ts new file mode 100644 index 000000000..332fc91e4 --- /dev/null +++ b/backend/src/entities/visualizations/panel-position/use-cases/generate-table-dashboard-with-ai.use.case.ts @@ -0,0 +1,517 @@ +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { IDataAccessObject } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object.interface.js'; +import { IDataAccessObjectAgent } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object-agent.interface.js'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { DashboardWidgetTypeEnum } from '../../../../enums/dashboard-widget-type.enum.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { AICoreService, AIProviderType, cleanAIJsonResponse } from '../../../../ai-core/index.js'; +import { GenerateTableDashboardWithAiDs } from '../data-structures/generate-table-dashboard-with-ai.ds.js'; +import { GeneratedPanelWithPositionDto } from '../dto/generated-panel-with-position.dto.js'; +import { IGenerateTableDashboardWithAi } from './panel-position-use-cases.interface.js'; +import { validateQuerySafety } from '../../panel/utils/check-query-is-safe.util.js'; +import { DashboardEntity } from '../../dashboard/dashboard.entity.js'; +import { PanelEntity } from '../../panel/panel.entity.js'; +import { PanelPositionEntity } from '../panel-position.entity.js'; + +interface AIGeneratedPanelResponse { + name: string; + description: string; + query_text: string; + panel_type: 'chart' | 'table' | 'counter' | 'text'; + chart_type?: 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; + panel_options?: { + label_column?: string; + value_column?: string; + series?: Array<{ + value_column: string; + label?: string; + color?: string; + }>; + stacked?: boolean; + horizontal?: boolean; + show_data_labels?: boolean; + legend?: { + show?: boolean; + position?: 'top' | 'bottom' | 'left' | 'right'; + }; + }; +} + +interface AISuggestedChart { + chart_description: string; + suggested_panel_type: string; + suggested_chart_type?: string; +} + +interface AIDashboardSuggestion { + dashboard_name: string; + dashboard_description: string; + charts: AISuggestedChart[]; +} + +const MAX_FEEDBACK_ITERATIONS = 3; +const DEFAULT_MAX_PANELS = 6; +const PANEL_WIDTH = 6; +const PANEL_HEIGHT = 4; +const PANELS_PER_ROW = 2; + +const EXPLAIN_SUPPORTED_TYPES: ReadonlySet = new Set([ + ConnectionTypesEnum.postgres, + ConnectionTypesEnum.agent_postgres, + ConnectionTypesEnum.mysql, + ConnectionTypesEnum.agent_mysql, + ConnectionTypesEnum.clickhouse, + ConnectionTypesEnum.agent_clickhouse, +]); + +interface TableInfo { + table_name: string; + columns: Array<{ name: string; type: string; nullable: boolean }>; +} + +@Injectable({ scope: Scope.REQUEST }) +export class GenerateTableDashboardWithAiUseCase + extends AbstractUseCase + implements IGenerateTableDashboardWithAi +{ + private readonly logger = new Logger(GenerateTableDashboardWithAiUseCase.name); + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly aiCoreService: AICoreService, + ) { + super(); + } + + public async implementation(inputData: GenerateTableDashboardWithAiDs): Promise<{ success: boolean }> { + const { connectionId, masterPassword, table_name, max_panels, dashboard_name } = inputData; + + const maxPanels = max_panels ?? DEFAULT_MAX_PANELS; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + const dao = getDataAccessObject(foundConnection); + + let tableInfo: TableInfo; + + try { + const structure = await dao.getTableStructure(table_name, null); + tableInfo = { + table_name: table_name, + columns: structure.map((col) => ({ + name: col.column_name, + type: col.data_type, + nullable: col.allow_null, + })), + }; + } catch (error) { + throw new BadRequestException(`Failed to get table structure for "${table_name}": ${error.message}`); + } + + if (tableInfo.columns.length === 0) { + throw new BadRequestException(`The specified table "${table_name}" does not have any columns or does not exist.`); + } + + const suggestionPrompt = this.buildDashboardSuggestionPrompt(tableInfo, foundConnection.type, maxPanels); + + const suggestionResponse = await this.aiCoreService.completeWithProvider(AIProviderType.BEDROCK, suggestionPrompt, { + temperature: 0.4, + }); + + const dashboardSuggestion = this.parseDashboardSuggestion(suggestionResponse); + + const effectiveDashboardName = + dashboard_name || dashboardSuggestion.dashboard_name || `${table_name} Dashboard`; + + const panelPromises = dashboardSuggestion.charts.slice(0, maxPanels).map((chart, index) => + this.generateSinglePanel(chart, tableInfo, dao, foundConnection.type as ConnectionTypesEnum, connectionId, index), + ); + + const results = await Promise.allSettled(panelPromises); + + const panels: GeneratedPanelWithPositionDto[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + panels.push(result.value); + } else { + this.logger.warn(`Panel generation failed: ${result.reason?.message || result.reason}`); + } + } + + if (panels.length === 0) { + throw new BadRequestException('Failed to generate any panels for the table. Please try again.'); + } + + const dashboardEntity = new DashboardEntity(); + dashboardEntity.name = effectiveDashboardName; + dashboardEntity.description = dashboardSuggestion.dashboard_description || null; + dashboardEntity.connection_id = connectionId; + const savedDashboard = await this._dbContext.dashboardRepository.saveDashboard(dashboardEntity); + + for (const panel of panels) { + const panelEntity = new PanelEntity(); + panelEntity.name = panel.name; + panelEntity.description = panel.description || null; + panelEntity.panel_type = panel.panel_type; + panelEntity.chart_type = panel.chart_type || null; + panelEntity.panel_options = panel.panel_options ? (panel.panel_options as unknown as string) : null; + panelEntity.query_text = panel.query_text; + panelEntity.connection_id = connectionId; + const savedPanel = await this._dbContext.panelRepository.save(panelEntity); + + const positionEntity = new PanelPositionEntity(); + positionEntity.position_x = panel.panel_position.position_x; + positionEntity.position_y = panel.panel_position.position_y; + positionEntity.width = panel.panel_position.width; + positionEntity.height = panel.panel_position.height; + positionEntity.dashboard_id = savedDashboard.id; + positionEntity.query_id = savedPanel.id; + await this._dbContext.panelPositionRepository.savePanelPosition(positionEntity); + } + + return { success: true }; + } + + private buildDashboardSuggestionPrompt(tableInfo: TableInfo, databaseType: string, maxPanels: number): string { + const schemaDescription = `Table: ${tableInfo.table_name}\n Columns:\n${tableInfo.columns.map((col) => ` - ${col.name}: ${col.type}${col.nullable ? ' (nullable)' : ''}`).join('\n')}`; + + return `You are a database analytics assistant. Analyze the following table schema and suggest ${maxPanels} useful chart/panel visualizations that would provide meaningful insights. + +DATABASE TYPE: ${databaseType} + +DATABASE SCHEMA: +${schemaDescription} + +Suggest diverse visualizations based on the column types: +- Numeric columns: counters (totals, averages), bar/line charts for distributions +- Date/timestamp columns: line charts for trends over time +- String/categorical columns: pie/doughnut charts for category distributions, bar charts for top N +- Boolean columns: pie charts for true/false distributions +- Combinations: group numeric data by categories or time periods + +Generate a JSON response with the following structure: +{ + "dashboard_name": "Descriptive name for the dashboard (max 100 chars)", + "dashboard_description": "Brief description of what this dashboard shows", + "charts": [ + { + "chart_description": "Detailed natural language description of what the chart should show, including specific columns, aggregations, and groupings to use", + "suggested_panel_type": "chart" | "counter" | "table", + "suggested_chart_type": "bar" | "line" | "pie" | "doughnut" | "polarArea" + } + ] +} + +IMPORTANT GUIDELINES: +1. Suggest exactly ${maxPanels} charts +2. Each chart_description should be specific enough to generate a SQL query +3. Include a mix of panel types (counters, charts, tables) for variety +4. Reference actual column names from the schema +5. Consider which visualizations are most meaningful for the data types present +6. Avoid duplicate or overly similar visualizations + +Respond ONLY with the JSON object, no additional text or explanation.`; + } + + private parseDashboardSuggestion(aiResponse: string): AIDashboardSuggestion { + try { + const cleanedResponse = cleanAIJsonResponse(aiResponse); + const parsed = JSON.parse(cleanedResponse) as AIDashboardSuggestion; + + if (!parsed.charts || !Array.isArray(parsed.charts) || parsed.charts.length === 0) { + throw new Error('Missing or empty charts array in AI response'); + } + + return parsed; + } catch (error) { + this.logger.error('Error parsing dashboard suggestion AI response:', error.message); + throw new BadRequestException( + 'Failed to generate dashboard suggestions from AI. Please try again.', + ); + } + } + + private async generateSinglePanel( + chart: AISuggestedChart, + tableInfo: TableInfo, + dao: IDataAccessObject | IDataAccessObjectAgent, + connectionType: ConnectionTypesEnum, + connectionId: string, + index: number, + ): Promise { + const prompt = this.buildPanelPrompt(chart.chart_description, tableInfo, connectionType); + + const aiResponse = await this.aiCoreService.completeWithProvider(AIProviderType.BEDROCK, prompt, { + temperature: 0.3, + }); + + const generatedPanel = this.parseAIResponse(aiResponse); + + validateQuerySafety(generatedPanel.query_text, connectionType); + + const refinedPanel = await this.validateAndRefineQueryWithExplain( + dao, + generatedPanel, + tableInfo, + connectionType, + chart.chart_description, + ); + + const row = Math.floor(index / PANELS_PER_ROW); + const col = index % PANELS_PER_ROW; + + return { + name: refinedPanel.name, + description: refinedPanel.description || null, + panel_type: this.mapPanelType(refinedPanel.panel_type), + chart_type: refinedPanel.chart_type || null, + panel_options: refinedPanel.panel_options + ? (refinedPanel.panel_options as unknown as Record) + : null, + query_text: refinedPanel.query_text, + connection_id: connectionId, + panel_position: { + position_x: col * PANEL_WIDTH, + position_y: row * PANEL_HEIGHT, + width: PANEL_WIDTH, + height: PANEL_HEIGHT, + dashboard_id: null, + }, + }; + } + + private buildPanelPrompt( + chartDescription: string, + tableInfo: TableInfo, + databaseType: string | ConnectionTypesEnum, + ): string { + const schemaDescription = `Table: ${tableInfo.table_name}\n Columns:\n${tableInfo.columns.map((col) => ` - ${col.name}: ${col.type}${col.nullable ? ' (nullable)' : ''}`).join('\n')}`; + + return `You are a database analytics assistant. Based on the user's chart description and the database schema, generate the SQL query and chart configuration. + +DATABASE TYPE: ${databaseType} + +DATABASE SCHEMA: +${schemaDescription} + +USER'S CHART DESCRIPTION: +"${chartDescription}" + +Generate a JSON response with the following structure: +{ + "name": "Short descriptive name for the chart (max 50 chars)", + "description": "Brief description of what the chart shows", + "query_text": "SELECT query that returns data for the chart. The query should return columns that can be used for labels and values. Use appropriate aggregations (COUNT, SUM, AVG, etc.) and GROUP BY clauses as needed. Always use the table name '${tableInfo.table_name}'.", + "panel_type": "chart" | "table" | "counter" | "text", + "chart_type": "bar" | "line" | "pie" | "doughnut" | "polarArea", + "panel_options": { + "label_column": "column name for labels/categories (x-axis)", + "value_column": "column name for values (y-axis) - use this for single series", + "series": [ + { + "value_column": "column name", + "label": "Series label", + "color": "#hex_color" + } + ], + "stacked": false, + "horizontal": false, + "show_data_labels": true, + "legend": { + "show": true, + "position": "top" + } + } +} + +IMPORTANT GUIDELINES: +1. Write valid ${databaseType} SQL syntax +2. Use appropriate column aliases that are clear and descriptive +3. For charts, ensure the query returns data suitable for visualization: + - For bar/line charts: one column for labels, one or more for values + - For pie/doughnut: one column for labels, one for values + - For counter: single value result +4. Choose chart_type based on the data characteristics: + - bar: comparisons between categories + - line: trends over time + - pie/doughnut: parts of a whole (percentages) + - polarArea: similar to pie but with equal angles +5. Use meaningful colors from this palette: #3366CC, #DC3912, #FF9900, #109618, #990099, #0099C6, #DD4477, #66AA00 +6. Set panel_options.label_column to the column that should be used for labels +7. For single value series, use value_column directly +8. For multiple series, use the series array + +Respond ONLY with the JSON object, no additional text or explanation.`; + } + + private parseAIResponse(aiResponse: string): AIGeneratedPanelResponse { + try { + const cleanedResponse = cleanAIJsonResponse(aiResponse); + const parsed = JSON.parse(cleanedResponse) as AIGeneratedPanelResponse; + + if (!parsed.name || !parsed.query_text || !parsed.panel_type) { + throw new Error('Missing required fields in AI response'); + } + + return parsed; + } catch (error) { + this.logger.error('Error parsing AI response:', error.message); + throw new BadRequestException( + 'Failed to generate panel configuration from AI. Please try again with a different description.', + ); + } + } + + private async validateAndRefineQueryWithExplain( + dao: IDataAccessObject | IDataAccessObjectAgent, + generatedPanel: AIGeneratedPanelResponse, + tableInfo: TableInfo, + connectionType: ConnectionTypesEnum, + chartDescription: string, + ): Promise { + if (!EXPLAIN_SUPPORTED_TYPES.has(connectionType)) { + return generatedPanel; + } + + let currentQuery = generatedPanel.query_text; + + for (let iteration = 0; iteration < MAX_FEEDBACK_ITERATIONS; iteration++) { + const explainResult = await this.runExplainQuery(dao, currentQuery, tableInfo.table_name); + + const correctionPrompt = this.buildQueryCorrectionPrompt( + currentQuery, + explainResult.success ? explainResult.result : explainResult.error, + !explainResult.success, + tableInfo, + connectionType, + chartDescription, + ); + + const aiResponse = await this.aiCoreService.completeWithProvider(AIProviderType.BEDROCK, correctionPrompt, { + temperature: 0.2, + }); + + const correctedQuery = this.cleanQueryResponse(aiResponse); + + validateQuerySafety(correctedQuery, connectionType); + + if (this.normalizeWhitespace(correctedQuery) === this.normalizeWhitespace(currentQuery)) { + this.logger.log(`Query accepted by AI without changes after EXPLAIN (iteration ${iteration + 1})`); + break; + } + + this.logger.log(`Query corrected by AI after EXPLAIN (iteration ${iteration + 1})`); + currentQuery = correctedQuery; + + if (explainResult.success) { + break; + } + } + + return { ...generatedPanel, query_text: currentQuery }; + } + + private async runExplainQuery( + dao: IDataAccessObject | IDataAccessObjectAgent, + query: string, + tableName: string, + ): Promise<{ success: boolean; result?: string; error?: string }> { + try { + const explainQuery = `EXPLAIN ${query.replace(/;\s*$/, '')}`; + const result = await (dao as IDataAccessObject).executeRawQuery(explainQuery, tableName); + return { success: true, result: JSON.stringify(result, null, 2) }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + private buildQueryCorrectionPrompt( + currentQuery: string, + explainResultOrError: string, + isError: boolean, + tableInfo: TableInfo, + connectionType: ConnectionTypesEnum, + chartDescription: string, + ): string { + const schemaDescription = `Table: ${tableInfo.table_name}\n Columns:\n${tableInfo.columns + .map((col) => ` - ${col.name}: ${col.type}${col.nullable ? ' (nullable)' : ''}`) + .join('\n')}`; + + const feedbackSection = isError + ? `The query FAILED with the following error:\n${explainResultOrError}\n\nPlease fix the query to resolve this error.` + : `The EXPLAIN output for the query is:\n${explainResultOrError}\n\nReview the execution plan. If the query has performance issues (full table scans on large datasets, inefficient joins, etc.), optimize it. If the query is already acceptable, return it unchanged.`; + + return `You are a database query optimization assistant. A SQL query was generated and needs validation. + +DATABASE TYPE: ${connectionType} + +DATABASE SCHEMA: +${schemaDescription} + +ORIGINAL USER REQUEST: +"${chartDescription}" + +CURRENT QUERY: +${currentQuery} + +${feedbackSection} + +IMPORTANT: +- Preserve the same column aliases used in the original query. +- Write valid ${connectionType} SQL syntax. +- Return ONLY the SQL query, no explanations, no markdown, no JSON wrapping.`; + } + + private cleanQueryResponse(aiResponse: string): string { + const cleaned = aiResponse + .trim() + .replace(/^```[a-zA-Z]*\n?/, '') + .replace(/```\s*$/, '') + .trim(); + + if (cleaned.startsWith('{')) { + try { + const parsed = JSON.parse(cleaned); + if (parsed.query_text) { + return parsed.query_text; + } + } catch { + // Not valid JSON, return as-is + } + } + + return cleaned; + } + + private normalizeWhitespace(query: string): string { + return query.replace(/\s+/g, ' ').trim(); + } + + private mapPanelType(type: string): DashboardWidgetTypeEnum { + switch (type) { + case 'chart': + return DashboardWidgetTypeEnum.Chart; + case 'table': + return DashboardWidgetTypeEnum.Table; + case 'counter': + return DashboardWidgetTypeEnum.Counter; + case 'text': + return DashboardWidgetTypeEnum.Text; + default: + return DashboardWidgetTypeEnum.Chart; + } + } +} diff --git a/backend/src/entities/visualizations/panel-position/use-cases/panel-position-use-cases.interface.ts b/backend/src/entities/visualizations/panel-position/use-cases/panel-position-use-cases.interface.ts index e17943c9f..3ce656905 100644 --- a/backend/src/entities/visualizations/panel-position/use-cases/panel-position-use-cases.interface.ts +++ b/backend/src/entities/visualizations/panel-position/use-cases/panel-position-use-cases.interface.ts @@ -2,6 +2,7 @@ import { InTransactionEnum } from '../../../../enums/in-transaction.enum.js'; import { CreatePanelPositionDs } from '../data-structures/create-panel-position.ds.js'; import { DeletePanelPositionDs } from '../data-structures/delete-panel-position.ds.js'; import { GeneratePanelPositionWithAiDs } from '../data-structures/generate-panel-position-with-ai.ds.js'; +import { GenerateTableDashboardWithAiDs } from '../data-structures/generate-table-dashboard-with-ai.ds.js'; import { UpdatePanelPositionDs } from '../data-structures/update-panel-position.ds.js'; import { FoundPanelPositionDto } from '../dto/found-panel-position.dto.js'; import { GeneratedPanelWithPositionDto } from '../dto/generated-panel-with-position.dto.js'; @@ -21,3 +22,7 @@ export interface IDeletePanelPosition { export interface IGeneratePanelPositionWithAi { execute(inputData: GeneratePanelPositionWithAiDs, inTransaction: InTransactionEnum): Promise; } + +export interface IGenerateTableDashboardWithAi { + execute(inputData: GenerateTableDashboardWithAiDs, inTransaction: InTransactionEnum): Promise<{ success: boolean }>; +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-dashboard-ai-generate-table-dashboard-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-dashboard-ai-generate-table-dashboard-e2e.test.ts new file mode 100644 index 000000000..87eb12883 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-dashboard-ai-generate-table-dashboard-e2e.test.ts @@ -0,0 +1,344 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { AICoreService } from '../../../src/ai-core/index.js'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { + createInitialTestUser, + registerUserAndReturnUserInfo, +} from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let testUtils: TestUtils; +let currentTest: string; + +const MOCK_DASHBOARD_SUGGESTION = JSON.stringify({ + dashboard_name: 'Test Analytics Dashboard', + dashboard_description: 'Automated dashboard for test table analysis', + charts: [ + { + chart_description: 'Show total count of records', + suggested_panel_type: 'counter', + }, + { + chart_description: 'Show distribution of records by name', + suggested_panel_type: 'chart', + suggested_chart_type: 'bar', + }, + ], +}); + +const MOCK_PANEL_RESPONSE = JSON.stringify({ + name: 'Record Count', + description: 'Total number of records', + query_text: 'SELECT COUNT(*) as total FROM test_table', + panel_type: 'counter', + panel_options: { + value_column: 'total', + }, +}); + +const MOCK_PANEL_RESPONSE_UNSAFE = JSON.stringify({ + name: 'Drop Table', + description: 'Unsafe query', + query_text: 'DROP TABLE test_table', + panel_type: 'table', +}); + +let mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; +let mockPanelResponse = MOCK_PANEL_RESPONSE; + +const mockAICoreService = { + completeWithProvider: async (_provider: string, prompt: string) => { + if (prompt.includes('chart/panel visualizations')) { + return mockDashboardSuggestion; + } + if (prompt.includes('query optimization assistant')) { + const match = prompt.match(/CURRENT QUERY:\n([\s\S]*?)\n\n/); + return match ? match[1].trim() : 'SELECT 1'; + } + return mockPanelResponse; + }, + complete: async () => mockPanelResponse, + chat: async () => ({ content: mockPanelResponse, responseId: faker.string.uuid() }), + streamChat: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + chatWithTools: async () => ({ content: mockPanelResponse, responseId: faker.string.uuid() }), + streamChatWithTools: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + streamChatWithToolsAndProvider: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + getDefaultProvider: () => 'bedrock', + setDefaultProvider: () => {}, + getAvailableProviders: () => [], +}; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }) + .overrideProvider(AICoreService) + .useValue(mockAICoreService) + .compile(); + + testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'POST /dashboard/generate-table-dashboard/:connectionId'; + +test.serial(`${currentTest} should generate and persist a table dashboard with AI`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const generateDashboardRO = JSON.parse(generateDashboard.text); + t.is(generateDashboard.status, 201); + t.deepEqual(generateDashboardRO, { success: true }); + + // Verify dashboard was persisted + const getDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const dashboards = JSON.parse(getDashboards.text); + t.is(getDashboards.status, 200); + t.true(Array.isArray(dashboards)); + + const generatedDashboard = dashboards.find((d: any) => d.name === 'Test Analytics Dashboard'); + t.truthy(generatedDashboard); + t.is(generatedDashboard.description, 'Automated dashboard for test table analysis'); + t.is(generatedDashboard.connection_id, connectionId); + + // Verify panel positions (widgets) were persisted in the dashboard + t.truthy(generatedDashboard.widgets); + t.is(generatedDashboard.widgets.length, 2); + + for (const widget of generatedDashboard.widgets) { + t.truthy(widget.id); + t.truthy(widget.query_id); + t.is(widget.dashboard_id, generatedDashboard.id); + t.is(widget.width, 6); + t.is(widget.height, 4); + } + + // Verify panels (saved queries) were persisted + const getSavedQueries = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-queries`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const queries = JSON.parse(getSavedQueries.text); + t.is(getSavedQueries.status, 200); + t.true(queries.length >= 2); + + const generatedPanels = queries.filter((q: any) => q.name === 'Record Count'); + t.is(generatedPanels.length, 2); + for (const panel of generatedPanels) { + t.truthy(panel.id); + t.is(panel.connection_id, connectionId); + t.truthy(panel.query_text); + } +}); + +test.serial(`${currentTest} should use custom dashboard name when provided`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const customDashboardName = 'My Custom AI Dashboard'; + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({ dashboard_name: customDashboardName }) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 201); + t.deepEqual(JSON.parse(generateDashboard.text), { success: true }); + + // Verify dashboard was created with the custom name + const getDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const dashboards = JSON.parse(getDashboards.text); + const customDashboard = dashboards.find((d: any) => d.name === customDashboardName); + t.truthy(customDashboard); +}); + +test.serial(`${currentTest} should reject when all AI panels have unsafe queries`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE_UNSAFE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); + const errorResponse = JSON.parse(generateDashboard.text); + t.truthy(errorResponse.message); +}); + +test.serial(`${currentTest} should fail for non-existent table`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=non_existent_table_xyz`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); +}); + +test.serial(`${currentTest} should fail without tableName query parameter`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); +}); diff --git a/backend/test/ava-tests/saas-tests/dashboard-ai-generate-table-dashboard-e2e.test.ts b/backend/test/ava-tests/saas-tests/dashboard-ai-generate-table-dashboard-e2e.test.ts new file mode 100644 index 000000000..8ed3c04a9 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/dashboard-ai-generate-table-dashboard-e2e.test.ts @@ -0,0 +1,338 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { AICoreService } from '../../../src/ai-core/index.js'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let testUtils: TestUtils; +let currentTest: string; + +const MOCK_DASHBOARD_SUGGESTION = JSON.stringify({ + dashboard_name: 'Test Analytics Dashboard', + dashboard_description: 'Automated dashboard for test table analysis', + charts: [ + { + chart_description: 'Show total count of records', + suggested_panel_type: 'counter', + }, + { + chart_description: 'Show distribution of records by name', + suggested_panel_type: 'chart', + suggested_chart_type: 'bar', + }, + ], +}); + +const MOCK_PANEL_RESPONSE = JSON.stringify({ + name: 'Record Count', + description: 'Total number of records', + query_text: 'SELECT COUNT(*) as total FROM test_table', + panel_type: 'counter', + panel_options: { + value_column: 'total', + }, +}); + +const MOCK_PANEL_RESPONSE_UNSAFE = JSON.stringify({ + name: 'Drop Table', + description: 'Unsafe query', + query_text: 'DROP TABLE test_table', + panel_type: 'table', +}); + +let mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; +let mockPanelResponse = MOCK_PANEL_RESPONSE; + +const mockAICoreService = { + completeWithProvider: async (_provider: string, prompt: string) => { + if (prompt.includes('chart/panel visualizations')) { + return mockDashboardSuggestion; + } + if (prompt.includes('query optimization assistant')) { + const match = prompt.match(/CURRENT QUERY:\n([\s\S]*?)\n\n/); + return match ? match[1].trim() : 'SELECT 1'; + } + return mockPanelResponse; + }, + complete: async () => mockPanelResponse, + chat: async () => ({ content: mockPanelResponse, responseId: faker.string.uuid() }), + streamChat: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + chatWithTools: async () => ({ content: mockPanelResponse, responseId: faker.string.uuid() }), + streamChatWithTools: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + streamChatWithToolsAndProvider: async () => ({ + *[Symbol.asyncIterator]() { + yield { type: 'text', content: mockPanelResponse, responseId: faker.string.uuid() }; + }, + }), + getDefaultProvider: () => 'bedrock', + setDefaultProvider: () => {}, + getAvailableProviders: () => [], +}; + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }) + .overrideProvider(AICoreService) + .useValue(mockAICoreService) + .compile(); + + testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'POST /dashboard/generate-table-dashboard/:connectionId'; + +test.serial(`${currentTest} should generate and persist a table dashboard with AI`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const generateDashboardRO = JSON.parse(generateDashboard.text); + t.is(generateDashboard.status, 201); + t.deepEqual(generateDashboardRO, { success: true }); + + // Verify dashboard was persisted + const getDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const dashboards = JSON.parse(getDashboards.text); + t.is(getDashboards.status, 200); + t.true(Array.isArray(dashboards)); + + const generatedDashboard = dashboards.find((d: any) => d.name === 'Test Analytics Dashboard'); + t.truthy(generatedDashboard); + t.is(generatedDashboard.description, 'Automated dashboard for test table analysis'); + t.is(generatedDashboard.connection_id, connectionId); + + // Verify panel positions (widgets) were persisted in the dashboard + t.truthy(generatedDashboard.widgets); + t.is(generatedDashboard.widgets.length, 2); + + for (const widget of generatedDashboard.widgets) { + t.truthy(widget.id); + t.truthy(widget.query_id); + t.is(widget.dashboard_id, generatedDashboard.id); + t.is(widget.width, 6); + t.is(widget.height, 4); + } + + // Verify panels (saved queries) were persisted + const getSavedQueries = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-queries`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const queries = JSON.parse(getSavedQueries.text); + t.is(getSavedQueries.status, 200); + t.true(queries.length >= 2); + + const generatedPanels = queries.filter((q: any) => q.name === 'Record Count'); + t.is(generatedPanels.length, 2); + for (const panel of generatedPanels) { + t.truthy(panel.id); + t.is(panel.connection_id, connectionId); + t.truthy(panel.query_text); + } +}); + +test.serial(`${currentTest} should use custom dashboard name when provided`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const customDashboardName = 'My Custom AI Dashboard'; + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({ dashboard_name: customDashboardName }) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 201); + t.deepEqual(JSON.parse(generateDashboard.text), { success: true }); + + // Verify dashboard was created with the custom name + const getDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const dashboards = JSON.parse(getDashboards.text); + const customDashboard = dashboards.find((d: any) => d.name === customDashboardName); + t.truthy(customDashboard); +}); + +test.serial(`${currentTest} should reject when all AI panels have unsafe queries`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE_UNSAFE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=${testTableName}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); + const errorResponse = JSON.parse(generateDashboard.text); + t.truthy(errorResponse.message); +}); + +test.serial(`${currentTest} should fail for non-existent table`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}?tableName=non_existent_table_xyz`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); +}); + +test.serial(`${currentTest} should fail without tableName query parameter`, async (t) => { + mockDashboardSuggestion = MOCK_DASHBOARD_SUGGESTION; + mockPanelResponse = MOCK_PANEL_RESPONSE; + + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const { token } = await registerUserAndReturnUserInfo(app); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createConnectionResponse.text).id; + t.is(createConnectionResponse.status, 201); + + const generateDashboard = await request(app.getHttpServer()) + .post(`/dashboard/generate-table-dashboard/${connectionId}`) + .send({}) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(generateDashboard.status, 400); +}); diff --git a/backend/test/ava-tests/saas-tests/dashboard-ai-widget-e2e.test.ts b/backend/test/ava-tests/saas-tests/dashboard-ai-widget-e2e.test.ts index f231d8a4b..c65b91a5c 100644 --- a/backend/test/ava-tests/saas-tests/dashboard-ai-widget-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/dashboard-ai-widget-e2e.test.ts @@ -30,9 +30,9 @@ const MOCK_AI_RESPONSE_CHART = JSON.stringify({ name: 'Sales by Category', description: 'Bar chart showing total sales grouped by category', query_text: 'SELECT category, SUM(amount) as total FROM orders GROUP BY category', - widget_type: 'chart', + panel_type: 'chart', chart_type: 'bar', - widget_options: { + panel_options: { label_column: 'category', value_column: 'total', show_data_labels: true, @@ -47,8 +47,8 @@ const MOCK_AI_RESPONSE_COUNTER = JSON.stringify({ name: 'Total Orders', description: 'Counter showing total number of orders', query_text: 'SELECT COUNT(*) as total FROM orders', - widget_type: 'counter', - widget_options: { + panel_type: 'counter', + panel_options: { value_column: 'total', }, }); @@ -57,7 +57,7 @@ const MOCK_AI_RESPONSE_UNSAFE_QUERY = JSON.stringify({ name: 'Delete All', description: 'Unsafe query', query_text: 'DELETE FROM orders', - widget_type: 'table', + panel_type: 'table', }); let mockResponse = MOCK_AI_RESPONSE_CHART; @@ -165,13 +165,13 @@ test.serial(`${currentTest} should generate a widget with AI for chart type`, as t.is(generateWidgetRO.name, 'Sales by Category'); t.is(generateWidgetRO.description, 'Bar chart showing total sales grouped by category'); - t.is(generateWidgetRO.widget_type, DashboardWidgetTypeEnum.Chart); + t.is(generateWidgetRO.panel_type, DashboardWidgetTypeEnum.Chart); t.is(generateWidgetRO.chart_type, 'bar'); t.is(generateWidgetRO.query_text, 'SELECT category, SUM(amount) as total FROM orders GROUP BY category'); t.is(generateWidgetRO.connection_id, connectionId); - t.truthy(generateWidgetRO.widget_options); - t.is(generateWidgetRO.widget_options.label_column, 'category'); - t.is(generateWidgetRO.widget_options.value_column, 'total'); + t.truthy(generateWidgetRO.panel_options); + t.is(generateWidgetRO.panel_options.label_column, 'category'); + t.is(generateWidgetRO.panel_options.value_column, 'total'); t.truthy(generateWidgetRO.panel_position); t.is(generateWidgetRO.panel_position.position_x, 0); @@ -233,7 +233,7 @@ test.serial(`${currentTest} should generate a counter widget with AI`, async (t) t.is(generateWidgetRO.name, 'Total Orders'); t.is(generateWidgetRO.description, 'Counter showing total number of orders'); - t.is(generateWidgetRO.widget_type, DashboardWidgetTypeEnum.Counter); + t.is(generateWidgetRO.panel_type, DashboardWidgetTypeEnum.Counter); t.is(generateWidgetRO.query_text, 'SELECT COUNT(*) as total FROM orders'); t.is(generateWidgetRO.connection_id, connectionId);