diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index f94ab0391..2498f2107 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -63,6 +63,173 @@ padding-bottom: 24px; } +/* AI generation section */ +.ai-section { + border: 1px solid var(--mat-sidenav-container-divider-color); + border-radius: 8px; + overflow: hidden; +} + +.ai-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + cursor: pointer; + user-select: none; + background-color: rgba(99, 102, 241, 0.05); +} + +@media (prefers-color-scheme: dark) { + .ai-section-header { + background-color: rgba(99, 102, 241, 0.1); + } +} + +.ai-section-header:hover { + background-color: rgba(99, 102, 241, 0.08); +} + +@media (prefers-color-scheme: dark) { + .ai-section-header:hover { + background-color: rgba(99, 102, 241, 0.15); + } +} + +.ai-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; +} + +.ai-icon { + color: var(--color-accentedPalette-500, #6366f1); + font-size: 20px; + width: 20px; + height: 20px; +} + +.ai-toggle-icon { + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .ai-toggle-icon { + color: rgba(255, 255, 255, 0.54); + } +} + +.ai-section-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-fields { + display: flex; + gap: 16px; + align-items: flex-start; +} + +@media (width <= 600px) { + .ai-fields { + flex-direction: column; + } +} + +.ai-table-field { + flex: 0 0 220px; +} + +@media (width <= 600px) { + .ai-table-field { + flex: 1; + width: 100%; + } +} + +.ai-description-field { + flex: 1; +} + +.ai-actions { + display: flex; + justify-content: flex-end; +} + +.ai-actions .button-spinner { + display: inline-block; + vertical-align: middle; + margin-right: 6px; +} + +/* Manual configuration section */ +.manual-section { + border: 1px solid var(--mat-sidenav-container-divider-color); + border-radius: 8px; + overflow: hidden; +} + +.manual-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + cursor: pointer; + user-select: none; +} + +.manual-section-header:hover { + background-color: rgba(0, 0, 0, 0.03); +} + +@media (prefers-color-scheme: dark) { + .manual-section-header:hover { + background-color: rgba(255, 255, 255, 0.03); + } +} + +.manual-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; +} + +.manual-section-title mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .manual-section-title mat-icon { + color: rgba(255, 255, 255, 0.54); + } +} + +.manual-toggle-icon { + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .manual-toggle-icon { + color: rgba(255, 255, 255, 0.54); + } +} + +.manual-section-body { + padding: 0 16px 16px; + display: flex; + flex-direction: column; + gap: 24px; +} + .query-details { display: flex; gap: 16px; diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 68d4016ad..866ab4b55 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -20,518 +20,592 @@

{{ isEditMode() ? 'Edit panel' : 'Create panel' }}

@if (!loading()) {
-
- - Query name - - - - - Description (optional) - - -
- -
-
-
-

SQL query

- -
-
- - + @if (aiTables().length > 0 && aiDashboardId()) { +
+
+
+ auto_awesome + Generate with AI +
+ {{ aiExpanded() ? 'expand_less' : 'expand_more' }}
-
- - @if (showResults()) { -
-
-
-

Chart configuration

- @if (executionTime() !== null) { - - Executed in {{ executionTime() }}ms - - } -
- -
- - Chart type - - @for (type of chartTypes; track type.value) { - {{ type.label }} + @if (aiExpanded()) { +
+
+ + Table + + @for (table of aiTables(); track table.table) { + {{ table.display_name || table.table }} } + Select the table to generate a chart for - - Label column - - @for (col of resultColumns(); track col) { - {{ col }} - } - + + Chart description + + Describe the chart you want in natural language - - @if (showLabelTypeOption()) { - - Label type - - @for (type of labelTypes; track type.value) { - {{ type.label }} - } - - - }
- -
-
-

Data series

- @if (!isPieType()) { - - Series mode - - Manual - Series from column - - +
+
+ {{ aiGenerating() ? 'Generating...' : 'Generate' }} + +
+
+ } +
+ } - @if (seriesMode() === 'column' && !isPieType()) { -
- - Series column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Categorical column to split into datasets - - - - Value column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Numeric column to chart - -
- } + + @if (showResults()) { +
+
+

Chart preview

+ @if (executionTime() !== null) { + + Executed in {{ executionTime() }}ms + + } +
- @if (seriesMode() === 'manual' || isPieType()) { - @if (!isPieType() || seriesList().length === 0) { - - } + @if (hasChartData()) { +
+ + +
+ } - @for (series of seriesList(); track $index; let i = $index) { -
-
- Series {{ i + 1 }} - @if (seriesList().length > 1) { - - } + @if (!hasChartData() && testResults().length > 0) { +
+

Add at least one data series to display the chart

+
+ } +
+ } + + @if (showResults() && testResults().length === 0) { +
+ info +

Query executed successfully but returned no results.

+
+ } + +
+
+
+ tune + Manual configuration +
+ {{ manualExpanded() ? 'expand_less' : 'expand_more' }} +
+ + @if (manualExpanded()) { +
+
+ + Query name + + + + + Description (optional) + + +
+ +
+
+
+

SQL query

+ +
+
+ + +
+
+ + @if (showResults()) { +
+
+
+

Chart configuration

-
- - Value column - - @for (col of resultColumns(); track col) { - {{ col }} + +
+ + Chart type + + @for (type of chartTypes; track type.value) { + {{ type.label }} } - - Label - + + Label column + + @for (col of resultColumns(); track col) { + {{ col }} + } + - @if (!isPieType()) { -
- Color -
- - @if (series.color) { - - } - @if (!series.color) { - Auto - } -
-
- } - - @if (!isPieType() && chartType() !== 'bar') { - - Point style - - @for (ps of pointStyles; track ps.value) { - {{ ps.label }} + @if (showLabelTypeOption()) { + + Label type + + @for (type of labelTypes; track type.value) { + {{ type.label }} } }
- @if (!isPieType()) { -
- @if (chartType() === 'line' || series.type === 'line') { - - Fill area - + +
+
+

Data series

+ @if (!isPieType()) { + + Series mode + + Manual + Series from column + + } +
- @if (chartType() === 'line' || series.type === 'line') { - - Tension - + @if (seriesMode() === 'column' && !isPieType()) { +
+ + Series column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + Categorical column to split into datasets - } - @if (seriesList().length > 1) { - Type override - - Default - Bar - Line + Value column + + @for (col of resultColumns(); track col) { + {{ col }} + } + Numeric column to chart +
+ } + + @if (seriesMode() === 'manual' || isPieType()) { + @if (!isPieType() || seriesList().length === 0) { + } -
- } -
- } - } -
-
- - - - - - Display options - - -
- @if (!isPieType()) { - - Stacked - - } + @for (series of seriesList(); track $index; let i = $index) { +
+
+ Series {{ i + 1 }} + @if (seriesList().length > 1) { + + } +
- @if (chartType() === 'bar') { - - Horizontal - - } +
+ + Value column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + + + + Label + + + + @if (!isPieType()) { +
+ Color +
+ + @if (series.color) { + + } + @if (!series.color) { + Auto + } +
+
+ } - - Show data labels - + @if (!isPieType() && chartType() !== 'bar') { + + Point style + + @for (ps of pointStyles; track ps.value) { + {{ ps.label }} + } + + + } +
- - Show legend - + @if (!isPieType()) { +
+ @if (chartType() === 'line' || series.type === 'line') { + + Fill area + + } + + @if (chartType() === 'line' || series.type === 'line') { + + Tension + + + } + + @if (seriesList().length > 1) { + + Type override + + Default + Bar + Line + + + } +
+ } +
+ } + } +
+
- @if (legendShow()) { - - Legend position - - @for (pos of legendPositions; track pos.value) { - {{ pos.label }} + + + + + + Display options + + +
+ @if (!isPieType()) { + + Stacked + } - - - } -
-
- - - - - Units & formatting - - -
- - Unit mode - - @for (mode of unitModes; track mode.value) { - {{ mode.label }} - } - - - - @if (unitMode() === 'custom') { -
- - Unit text - - - - Unit position - - Prefix ($100) - Suffix (100ms) - - -
- } + @if (chartType() === 'bar') { + + Horizontal + + } - @if (unitMode() === 'convert') { - - Source unit - - @for (group of convertUnitPresets; track group.group) { - - @for (unit of group.units; track unit.value) { - {{ unit.label }} - } - + + Show data labels + + + + Show legend + + + @if (legendShow()) { + + Legend position + + @for (pos of legendPositions; track pos.value) { + {{ pos.label }} + } + + } - - Values auto-convert to the best readable unit - - } +
+
+ + + + + Units & formatting + + +
+ + Unit mode + + @for (mode of unitModes; track mode.value) { + {{ mode.label }} + } + + - @if (unitMode() !== 'convert') { -
- - Decimal places - - -
+ @if (unitMode() === 'custom') { +
+ + Unit text + + + + + Unit position + + Prefix ($100) + Suffix (100ms) + + +
+ } - - Thousands separator - + @if (unitMode() === 'convert') { + + Source unit + + @for (group of convertUnitPresets; track group.group) { + + @for (unit of group.units; track unit.value) { + {{ unit.label }} + } + + } + + Values auto-convert to the best readable unit + + } - - Compact notation (1K, 1M, 1B) - - } -
-
- - - @if (!isPieType()) { - - - Axis configuration - - -
-

Y-Axis

-
- - Title - - + @if (unitMode() !== 'convert') { +
+ + Decimal places + + +
- - Scale type - - @for (st of scaleTypes; track st.value) { - {{ st.label }} - } - - -
+ + Thousands separator + -
- - Min - - + + Compact notation (1K, 1M, 1B) + + } +
+ - - Max - - -
+ + @if (!isPieType()) { + + + Axis configuration + + +
+

Y-Axis

+
+ + Title + + + + + Scale type + + @for (st of scaleTypes; track st.value) { + {{ st.label }} + } + + +
- - Begin at zero - +
+ + Min + + + + + Max + + +
-

X-Axis

- - Title - - -
-
- } + + Begin at zero + - - - - Data options - - -
-
- - Sort by - - @for (opt of sortOptions; track opt.value) { - {{ opt.label }} - } - - - - - Limit - - -
-
-
- - - - - Custom color palette - - -
-
- @for (color of colorPalette(); track $index; let i = $index) { -
- - -
- } -
-
- - @if (colorPalette().length === 0) { - +

X-Axis

+ + Title + + +
+ } -
-
- - -
-
-

Chart preview

-
+ + + + Data options + + +
+
+ + Sort by + + @for (opt of sortOptions; track opt.value) { + {{ opt.label }} + } + + - @if (hasChartData()) { -
- - + + Limit + + +
+
+ + + + + + Custom color palette + + +
+
+ @for (color of colorPalette(); track $index; let i = $index) { +
+ + +
+ } +
+
+ + @if (colorPalette().length === 0) { + + } +
+
+
+
} +
- @if (!hasChartData() && testResults().length > 0) { -
-

Add at least one data series to display the chart

+ @if (showResults() && testResults().length > 0) { +
+
+

Query results ({{ testResults().length }} rows)

- } -
+
+ + @for (column of resultColumns(); track column) { + + + + + } + + + +
{{ column }}{{ row[column] }}
+
+
+ }
}
- - @if (showResults() && testResults().length > 0) { -
-
-

Query results ({{ testResults().length }} rows)

-
-
- - @for (column of resultColumns(); track column) { - - - - - } - - - -
{{ column }}{{ row[column] }}
-
-
- } - - @if (showResults() && testResults().length === 0) { -
- info -

Query executed successfully but returned no results.

-
- }
}
diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts index 99c05574a..09ac816c7 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts @@ -17,6 +17,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; +import { firstValueFrom } from 'rxjs'; import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; import { ChartAxisConfig, @@ -26,10 +27,14 @@ import { ChartType, ChartUnitConfig, ChartWidgetOptions, + GeneratedPanelWithPosition, TestQueryResult, } from 'src/app/models/saved-query'; +import { TableProperties } from 'src/app/models/table'; import { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService } from 'src/app/services/dashboards.service'; import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { TablesService } from 'src/app/services/tables.service'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { DashboardsSidebarComponent } from '../../dashboards/dashboards-sidebar/dashboards-sidebar.component'; import { AlertComponent } from '../../ui-components/alert/alert.component'; @@ -124,6 +129,16 @@ export class ChartEditComponent implements OnInit { // Color palette protected colorPalette = signal([]); + // AI generation + protected aiDescription = signal(''); + protected aiTableName = signal(''); + protected aiGenerating = signal(false); + protected aiTables = signal([]); + protected aiDashboardId = signal(null); + protected aiExpanded = signal(true); + protected manualExpanded = signal(false); + protected canGenerate = computed(() => !!this.aiDescription().trim() && !!this.aiTableName() && !this.aiGenerating()); + public chartTypes: { value: ChartType; label: string }[] = [ { value: 'bar', label: 'Bar Chart' }, { value: 'line', label: 'Line Chart' }, @@ -358,6 +373,8 @@ export class ChartEditComponent implements OnInit { private _savedQueries = inject(SavedQueriesService); private _connections = inject(ConnectionsService); + private _dashboards = inject(DashboardsService); + private _tables = inject(TablesService); private _uiSettings = inject(UiSettingsService); private route = inject(ActivatedRoute); private router = inject(Router); @@ -372,6 +389,14 @@ export class ChartEditComponent implements OnInit { const pageTitle = this.isEditMode() ? 'Edit Query' : 'Create Query'; this.title.setTitle(`${pageTitle} | ${title || 'Rocketadmin'}`); }); + + // Watch for dashboards to become available for AI feature + effect(() => { + const dashboards = this._dashboards.dashboards(); + if (dashboards.length > 0 && !this.aiDashboardId()) { + this.aiDashboardId.set(dashboards[0].id); + } + }); } ngOnInit(): void { @@ -382,10 +407,14 @@ export class ChartEditComponent implements OnInit { this.codeEditorTheme = this._uiSettings.editorTheme; if (this.isEditMode()) { + this.manualExpanded.set(true); + this.aiExpanded.set(false); this.loadSavedQuery(); } else { this.loading.set(false); } + + this._loadAiPrerequisites(); } async loadSavedQuery(): Promise { @@ -613,4 +642,100 @@ export class ChartEditComponent implements OnInit { this.saving.set(false); } } + + async generateWithAi(): Promise { + const dashboardId = this.aiDashboardId(); + if (!dashboardId || !this.aiTableName() || !this.aiDescription().trim()) return; + + this.aiGenerating.set(true); + + try { + const result = await this._dashboards.generateWidgetWithAi(dashboardId, this.connectionId(), this.aiTableName(), { + chart_description: this.aiDescription(), + }); + + if (result) { + this._applyAiResponse(result); + this.aiExpanded.set(false); + this.manualExpanded.set(true); + await this.testQuery(); + } + } finally { + this.aiGenerating.set(false); + } + + this.angulartics2.eventTrack.next({ + action: 'Charts: AI generation used', + }); + posthog.capture('Charts: AI generation used'); + } + + private _applyAiResponse(result: GeneratedPanelWithPosition): void { + if (result.name) this.queryName.set(result.name); + if (result.description) this.queryDescription.set(result.description); + if (result.query_text) { + this.queryText.set(result.query_text); + this.codeModel.set({ language: 'sql', uri: 'query.sql', value: result.query_text }); + } + + if (result.chart_type) { + const validTypes: ChartType[] = ['bar', 'line', 'pie', 'doughnut', 'polarArea']; + if (validTypes.includes(result.chart_type as ChartType)) { + this.chartType.set(result.chart_type as ChartType); + } + } + + if (result.widget_options) { + const opts = result.widget_options as Partial; + + if (opts.label_column) this.labelColumn.set(opts.label_column); + if (opts.label_type) this.labelType.set(opts.label_type); + + // Series + if (opts.series_column) { + this.seriesMode.set('column'); + this.seriesColumn.set(opts.series_column); + if (opts.value_column) this.seriesValueColumn.set(opts.value_column); + } else if (opts.series?.length) { + this.seriesMode.set('manual'); + this.seriesList.set([...opts.series]); + } else if (opts.value_column) { + this.seriesMode.set('manual'); + this.seriesList.set([{ value_column: opts.value_column }]); + } + + // Display options + if (opts.stacked) this.stacked.set(true); + if (opts.horizontal) this.horizontal.set(true); + if (opts.show_data_labels) this.showDataLabels.set(true); + + // Legend + if (opts.legend) { + if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show); + if (opts.legend.position) this.legendPosition.set(opts.legend.position); + } + + // Color palette + if (opts.color_palette?.length) { + this.colorPalette.set([...opts.color_palette]); + } + } + } + + private async _loadAiPrerequisites(): Promise { + const connectionId = this.connectionId(); + if (!connectionId) return; + + try { + const tables = await firstValueFrom(this._tables.fetchTables(connectionId)); + if (tables?.length) { + this.aiTables.set(tables); + } + } catch { + // Tables loading failed - AI feature will be unavailable + } + + // Trigger dashboards loading; the effect in constructor picks up the result + this._dashboards.setActiveConnection(connectionId); + } } diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts index b9b914c7e..63340f4d1 100644 --- a/frontend/src/app/models/saved-query.ts +++ b/frontend/src/app/models/saved-query.ts @@ -106,3 +106,20 @@ export interface TestQueryResult { data: Record[]; execution_time_ms: number; } + +export interface GeneratedPanelWithPosition { + name: string; + description: string | null; + widget_type: DashboardWidgetType; + chart_type: string | null; + widget_options: Record | null; + query_text: string; + connection_id: string; + panel_position: { + position_x: number; + position_y: number; + width: number; + height: number; + dashboard_id: string; + }; +} diff --git a/frontend/src/app/services/dashboards.service.ts b/frontend/src/app/services/dashboards.service.ts index 0083ac490..f9738a304 100644 --- a/frontend/src/app/services/dashboards.service.ts +++ b/frontend/src/app/services/dashboards.service.ts @@ -8,6 +8,7 @@ import { UpdateDashboardPayload, UpdateWidgetPayload, } from '../models/dashboard'; +import { GeneratedPanelWithPosition } from '../models/saved-query'; import { ApiService } from './api.service'; export type DashboardUpdateEvent = 'created' | 'updated' | 'deleted' | ''; @@ -138,6 +139,19 @@ export class DashboardsService { return this._api.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload); } + async generateWidgetWithAi( + dashboardId: string, + connectionId: string, + tableName: string, + payload: { chart_description: string; name?: string }, + ): Promise { + return this._api.post( + `/dashboard/${dashboardId}/widget/generate/${connectionId}`, + payload, + { params: { tableName } }, + ); + } + async deleteWidget(connectionId: string, dashboardId: string, widgetId: string): Promise { const widget = await this._api.delete( `/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`,