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)
-
-
-
-
-
-
-
-
-
-
+ @if (aiTables().length > 0 && aiDashboardId()) {
+
+
-
-
- @if (showResults()) {
-
-
-
-
-
-
- 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 }}
- }
-
-
- }
-
-
-
+
+ }
+
+ }
- @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()) {
+
+
- @if (seriesMode() === 'manual' || isPieType()) {
- @if (!isPieType() || seriesList().length === 0) {
-
- }
+ @if (hasChartData()) {
+
+ }
- @for (series of seriesList(); track $index; let i = $index) {
-
-
+ }
+
+ @if (showResults() && testResults().length === 0) {
+
+
info
+
Query executed successfully but returned no results.
+
+ }
+
+
+
+
+ @if (manualExpanded()) {
+
+
+
+ Query name
+
+
+
+
+ Description (optional)
+
+
+
+
+
+
+
+ @if (showResults()) {
+
+
+
-
-
- 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
-
+
+
- }
-
- }
- }
-
-
-
-
-
-
-
- Display options
-
-
-
- @if (!isPieType()) {
-
- Stacked
-
- }
+ @for (series of seriesList(); track $index; let i = $index) {
+
+
- @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
-
+
+ Thousands separator
+
-
-
- Min
-
-
+
+ Compact notation (1K, 1M, 1B)
+
+ }
+
+
-
- Max
-
-
-
+
+ @if (!isPieType()) {
+
+
+ Axis configuration
+
+
+
-
- }
+
+ 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
+
+
+
+
}
-
-
-
-
-
-
+
+
+
+ Data options
+
+
+
+
+
+ Sort by
+
+ @for (opt of sortOptions; track opt.value) {
+ {{ opt.label }}
+ }
+
+
- @if (hasChartData()) {
-
+
+
+
+
+
+
+ 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) {
+
+
- }
-
+
+
+ @for (column of resultColumns(); track column) {
+
+ | {{ column }} |
+ {{ row[column] }} |
+
+ }
+
+
+
+
+
+
+ }
}
-
- @if (showResults() && testResults().length > 0) {
-
-
-
-
- @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}`,