mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
902b8ebdd3
commit
a13b60d0ef
5 changed files with 246 additions and 60 deletions
7
.changeset/sweet-pumas-mix.md
Normal file
7
.changeset/sweet-pumas-mix.md
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue