feat: Support import/export for dashboards with raw sql tables (#1858)

Closes HDX-3580

## Summary

This PR updates the dashboard import and export experience to support Raw SQL-driven chart tiles, which have a connection to map rather than a source.

### Screenshots or video

https://github.com/user-attachments/assets/354098fa-d1bf-454f-a29f-41444b28ef46

### How to test locally or on Vercel

Checkout locally, create a dashboard with a raw SQL table tile, export it, import it.

### References



- Linear Issue: Closes HDX-3583
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-06 09:55:31 -05:00 committed by GitHub
parent 902b8ebdd3
commit a13b60d0ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 246 additions and 60 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Support Raw SQL Chart Configs in Dashboard import/export

View file

@ -2,14 +2,16 @@ import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { filter } from 'lodash';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { StringParam, useQueryParam } from 'use-query-params';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { DashboardTemplateSchema } from '@hyperdx/common-utils/dist/types';
import {
DashboardTemplateSchema,
SavedChartConfig,
} from '@hyperdx/common-utils/dist/types';
import {
Button,
Collapse,
@ -34,6 +36,7 @@ import {
import { PageHeader } from './components/PageHeader';
import SelectControlled from './components/SelectControlled';
import { useBrandDisplayName } from './theme/ThemeProvider';
import { useConnections } from './connection';
import { useCreateDashboard, useUpdateDashboard } from './dashboard';
import { withAppNav } from './layout';
import { useSources } from './source';
@ -180,47 +183,54 @@ function FileSelection({
);
}
const SourceResolutionForm = z.object({
const MappingForm = z.object({
dashboardName: z.string().min(1),
sourceMappings: z.array(z.string()),
connectionMappings: z.array(z.string()),
filterSourceMappings: z.array(z.string()).optional(),
});
type SourceResolutionFormValues = z.infer<typeof SourceResolutionForm>;
type MappingFormValues = z.infer<typeof MappingForm>;
function Mapping({ input }: { input: Input }) {
const router = useRouter();
const { data: sources } = useSources();
const { data: connections } = useConnections();
const [dashboardId] = useQueryParam('dashboardId', StringParam);
const { handleSubmit, getFieldState, control, setValue } =
useForm<SourceResolutionFormValues>({
resolver: zodResolver(SourceResolutionForm),
useForm<MappingFormValues>({
resolver: zodResolver(MappingForm),
defaultValues: {
dashboardName: input.name,
sourceMappings: input.tiles.map(() => undefined),
sourceMappings: input.tiles.map(() => ''),
connectionMappings: input.tiles.map(() => ''),
},
});
// When the inputs change, reset the form
// When the input changes, reset the form
useEffect(() => {
if (!input || !sources) return;
if (!input || !sources || !connections) return;
const sourceMappings = input.tiles.map(tile => {
// find matching source name
const configSource = !isRawSqlSavedChartConfig(tile.config)
? tile.config.source
: undefined;
const config = tile.config as SavedChartConfig;
if (isRawSqlSavedChartConfig(config)) return '';
const match = sources.find(
source =>
configSource &&
source.name.toLowerCase() === configSource.toLowerCase(),
source => source.name.toLowerCase() === config.source.toLowerCase(),
);
return match?.id || '';
});
const connectionMappings = input.tiles.map(tile => {
const config = tile.config as SavedChartConfig;
if (!isRawSqlSavedChartConfig(config)) return '';
const match = connections.find(
conn => conn.name.toLowerCase() === config.connection.toLowerCase(),
);
return match?.id || '';
});
const filterSourceMappings = input.filters?.map(filter => {
// find matching source name
const match = sources.find(
source => source.name.toLowerCase() === filter.source.toLowerCase(),
);
@ -228,14 +238,17 @@ function Mapping({ input }: { input: Input }) {
});
setValue('sourceMappings', sourceMappings);
setValue('connectionMappings', connectionMappings);
setValue('filterSourceMappings', filterSourceMappings);
}, [setValue, sources, input]);
}, [setValue, sources, connections, input]);
const isUpdatingRef = useRef(false);
const sourceMappings = useWatch({ control, name: 'sourceMappings' });
const connectionMappings = useWatch({ control, name: 'connectionMappings' });
const prevSourceMappingsRef = useRef(sourceMappings);
const prevConnectionMappingsRef = useRef(connectionMappings);
// HDX-3583: Extend this to support connection matching for Raw SQL-based charts.
// Propagate source mapping changes to other tiles/filters with the same input source
useEffect(() => {
if (isUpdatingRef.current) return;
if (!sourceMappings || !input.tiles) return;
@ -249,13 +262,17 @@ function Mapping({ input }: { input: Input }) {
prevSourceMappingsRef.current = sourceMappings;
const inputTile = input.tiles[changedIdx];
if (!inputTile) return;
const inputTileConfig = inputTile?.config;
if (!inputTileConfig || isRawSqlSavedChartConfig(inputTileConfig)) return;
const sourceId = sourceMappings[changedIdx] ?? '';
const inputTileSource = !isRawSqlSavedChartConfig(inputTile.config)
? inputTile.config.source
: undefined;
const inputTileSource = inputTileConfig.source;
const keysForTilesWithMatchingSource = input.tiles
.map((tile, index) => ({ ...tile, index }))
.map((tile, index) => ({
config: tile.config,
index,
}))
.filter(
tile =>
!isRawSqlSavedChartConfig(tile.config) &&
@ -270,30 +287,76 @@ function Mapping({ input }: { input: Input }) {
.map(({ index }) => `filterSourceMappings.${index}` as const) ?? [];
isUpdatingRef.current = true;
for (const key of [
...keysForTilesWithMatchingSource,
...keysForFiltersWithMatchingSource,
]) {
const fieldState = getFieldState(key);
// Only set if the field has not been modified
if (!fieldState.isDirty) {
setValue(key, sourceId, {
shouldValidate: true,
});
if (!getFieldState(key).isDirty) {
setValue(key, sourceId, { shouldValidate: true });
}
}
isUpdatingRef.current = false;
}, [sourceMappings, input.tiles, input.filters, getFieldState, setValue]);
// Propagate connection mapping changes to other RawSQL tiles with the same input connection
useEffect(() => {
if (isUpdatingRef.current) return;
if (!connectionMappings || !input.tiles) return;
const changedIdx = connectionMappings.findIndex(
(mapping, idx) => mapping !== prevConnectionMappingsRef.current?.[idx],
);
if (changedIdx === -1) return;
prevConnectionMappingsRef.current = connectionMappings;
const inputTile = input.tiles[changedIdx];
const inputTileConfig = inputTile?.config as SavedChartConfig | undefined;
if (!inputTileConfig || !isRawSqlSavedChartConfig(inputTileConfig)) return;
const connectionId = connectionMappings[changedIdx] ?? '';
const inputTileConnection = inputTileConfig.connection;
const keysForTilesWithMatchingConnection = input.tiles
.map((tile, index) => ({
config: tile.config as SavedChartConfig,
index,
}))
.filter(
tile =>
isRawSqlSavedChartConfig(tile.config) &&
tile.config.connection === inputTileConnection,
)
.map(({ index }) => `connectionMappings.${index}` as const);
isUpdatingRef.current = true;
for (const key of keysForTilesWithMatchingConnection) {
if (!getFieldState(key).isDirty) {
setValue(key, connectionId, { shouldValidate: true });
}
}
isUpdatingRef.current = false;
}, [connectionMappings, input.tiles, getFieldState, setValue]);
const createDashboard = useCreateDashboard();
const updateDashboard = useUpdateDashboard();
const onSubmit = async (data: SourceResolutionFormValues) => {
const onSubmit = async (data: MappingFormValues) => {
try {
// Zip the source mappings with the input tiles
// Zip the source/connection mappings with the input tiles
const zippedTiles = input.tiles.map((tile, idx) => {
if (isRawSqlSavedChartConfig(tile.config)) {
const connection = connections?.find(
conn => conn.id === data.connectionMappings[idx],
);
return {
...tile,
config: {
...tile.config,
connection: connection!.id,
},
};
}
const source = sources?.find(
source => source.id === data.sourceMappings[idx],
);
@ -372,27 +435,42 @@ function Mapping({ input }: { input: Input }) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{input.tiles.map((tile, i) => (
<Table.Tr key={tile.id}>
<Table.Td>{tile.config.name}</Table.Td>
<Table.Td>
{!isRawSqlSavedChartConfig(tile.config)
? tile.config.source
: ''}
</Table.Td>
<Table.Td>
<SelectControlled
control={control}
name={`sourceMappings.${i}`}
data={sources?.map(source => ({
value: source.id,
label: source.name,
}))}
placeholder="Select a source"
/>
</Table.Td>
</Table.Tr>
))}
{input.tiles.map((tile, i) => {
const config = tile.config;
const isRawSql = isRawSqlSavedChartConfig(config);
const inputSourceName = isRawSql
? `${config.connection} (Connection)`
: `${config.source} (Source)`;
return (
<Table.Tr key={tile.id}>
<Table.Td>{tile.config.name}</Table.Td>
<Table.Td>{inputSourceName}</Table.Td>
<Table.Td>
{isRawSql ? (
<SelectControlled
control={control}
name={`connectionMappings.${i}`}
data={connections?.map(conn => ({
value: conn.id,
label: conn.name,
}))}
placeholder="Select a connection"
/>
) : (
<SelectControlled
control={control}
name={`sourceMappings.${i}`}
data={sources?.map(source => ({
value: source.id,
label: source.name,
}))}
placeholder="Select a source"
/>
)}
</Table.Td>
</Table.Tr>
);
})}
{input.filters?.map((filter, i) => (
<Table.Tr key={filter.id}>
<Table.Td>{filter.name} (filter)</Table.Td>

View file

@ -21,7 +21,6 @@ import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/util
import {
isBuilderChartConfig,
isBuilderSavedChartConfig,
isRawSqlChartConfig,
isRawSqlSavedChartConfig,
} from '@hyperdx/common-utils/dist/guards';
import {
@ -98,6 +97,7 @@ import { useBrandDisplayName } from './theme/ThemeProvider';
import { parseAsStringEncoded } from './utils/queryParsers';
import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils';
import { IS_LOCAL_MODE } from './config';
import { useConnections } from './connection';
import { useDashboard } from './dashboard';
import DashboardFilters from './DashboardFilters';
import DashboardFiltersModal from './DashboardFiltersModal';
@ -733,6 +733,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
});
const { data: sources } = useSources();
const { data: connections } = useConnections();
const [highlightedTileId] = useQueryState('highlightedTileId');
const tableConnections = useMemo(() => {
@ -1294,6 +1295,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
dashboard,
// TODO: fix this type issue
sources as TSourceUnion[],
connections,
),
dashboard?.name,
);

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { isBuilderSavedChartConfig } from '@/guards';
import {
BuilderChartConfigWithDateRange,
Connection,
DashboardSchema,
MetricsDataType,
SourceKind,
@ -751,6 +752,96 @@ describe('utils', () => {
level: 0.95,
});
});
it('should convert connection IDs to names for RawSQL tiles', () => {
const dashboard: z.infer<typeof DashboardSchema> = {
id: 'dashboard1',
name: 'SQL Dashboard',
tags: [],
tiles: [
{
id: 'tile1',
config: {
name: 'SQL Tile',
configType: 'sql',
sqlTemplate: 'SELECT 1',
connection: 'conn1',
},
x: 0,
y: 0,
w: 6,
h: 6,
},
{
id: 'tile2',
config: {
name: 'Another SQL Tile',
configType: 'sql',
sqlTemplate: 'SELECT 2',
connection: 'conn2',
},
x: 6,
y: 0,
w: 6,
h: 6,
},
],
};
const connections: Connection[] = [
{
id: 'conn1',
name: 'Production DB',
host: 'http://localhost:8123',
username: 'default',
},
{
id: 'conn2',
name: 'Staging DB',
host: 'http://localhost:8124',
username: 'default',
},
];
const template = convertToDashboardTemplate(dashboard, [], connections);
expect(template.tiles[0].config).toMatchObject({
configType: 'sql',
connection: 'Production DB',
});
expect(template.tiles[1].config).toMatchObject({
configType: 'sql',
connection: 'Staging DB',
});
});
it('should fall back to empty string for unknown connection IDs in RawSQL tiles', () => {
const dashboard: z.infer<typeof DashboardSchema> = {
id: 'dashboard1',
name: 'SQL Dashboard',
tags: [],
tiles: [
{
id: 'tile1',
config: {
name: 'SQL Tile',
configType: 'sql',
sqlTemplate: 'SELECT 1',
connection: 'unknown-conn',
},
x: 0,
y: 0,
w: 6,
h: 6,
},
],
};
const template = convertToDashboardTemplate(dashboard, [], []);
expect(template.tiles[0].config).toMatchObject({
configType: 'sql',
connection: '',
});
});
});
describe('isJsonExpression', () => {

View file

@ -5,11 +5,12 @@ import { z } from 'zod';
export { default as objectHash } from 'object-hash';
import { isBuilderSavedChartConfig } from '@/guards';
import { isBuilderSavedChartConfig, isRawSqlSavedChartConfig } from '@/guards';
import {
BuilderChartConfig,
BuilderChartConfigWithDateRange,
BuilderChartConfigWithOptTimestamp,
Connection,
DashboardFilter,
DashboardFilterSchema,
DashboardSchema,
@ -460,6 +461,7 @@ type TileTemplate = z.infer<typeof TileTemplateSchema>;
export function convertToDashboardTemplate(
input: Dashboard,
sources: TSourceUnion[],
connections: Connection[] = [],
): DashboardTemplate {
const output: DashboardTemplate = {
version: '0.1.0',
@ -470,15 +472,21 @@ export function convertToDashboardTemplate(
const convertToTileTemplate = (
input: Dashboard['tiles'][0],
sources: TSourceUnion[],
connections: Connection[],
): TileTemplate => {
const tile = TileTemplateSchema.strip().parse(structuredClone(input));
// Extract name from source or default to '' if not found
// Raw SQL configs don't have a source field, so only update builder configs
// Extract name from source/connection or default to '' if not found
const tileConfig = tile.config;
if (isBuilderSavedChartConfig(tileConfig)) {
tileConfig.source = (
sources.find(source => source.id === tileConfig.source) ?? { name: '' }
).name;
} else if (isRawSqlSavedChartConfig(tileConfig)) {
tileConfig.connection = (
connections.find(conn => conn.id === tileConfig.connection) ?? {
name: '',
}
).name;
}
return tile;
};
@ -495,7 +503,7 @@ export function convertToDashboardTemplate(
};
for (const tile of input.tiles) {
output.tiles.push(convertToTileTemplate(tile, sources));
output.tiles.push(convertToTileTemplate(tile, sources, connections));
}
if (input.filters) {