add dashboard import/export functionality (#1204)

Adds Dashboard Import/Export UI to support sharing premade dashboards

<img width="286" height="171" alt="Screenshot 2025-09-24 at 10 10 55 AM" src="https://github.com/user-attachments/assets/dbc61cad-a7e6-42c6-85b8-de049beb7d56" />
<img width="1037" height="625" alt="Screenshot 2025-09-24 at 10 10 48 AM" src="https://github.com/user-attachments/assets/4233bdd8-fb73-4c8a-953f-5a59f5e53108" />

Fixes HDX-2463
This commit is contained in:
Brandon Pereira 2025-09-25 07:26:32 -06:00 committed by GitHub
parent 816f90a392
commit 24314a9605
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 606 additions and 19 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
add dashboard import/export functionality

View file

@ -8,6 +8,7 @@ import { NextAdapter } from 'next-query-params';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '../styles/globals.css';
import '../styles/app.scss';

View file

@ -34,6 +34,7 @@
"@lezer/highlight": "^1.2.0",
"@mantine/core": "7.9.2",
"@mantine/dates": "^7.11.2",
"@mantine/dropzone": "^8.3.1",
"@mantine/form": "^7.11.2",
"@mantine/hooks": "7.9.2",
"@mantine/notifications": "^7.9.2",

View file

@ -26,6 +26,7 @@ import { useBackground, useUserPreferences } from '@/useUserPreferences';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@styles/globals.css';
import '@styles/app.scss';
import 'uplot/dist/uPlot.min.css';

View file

@ -0,0 +1,3 @@
import DBDashboardImportPage from '@/DBDashboardImportPage';
export default DBDashboardImportPage;

View file

@ -0,0 +1,396 @@
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { Container } from 'react-bootstrap';
import { Controller, useForm } from 'react-hook-form';
import { StringParam, useQueryParam } from 'use-query-params';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { DashboardTemplateSchema } from '@hyperdx/common-utils/dist/types';
import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/utils';
import {
Button,
Collapse,
Group,
Input,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconChevronRight,
IconFile,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { PageHeader } from './components/PageHeader';
import SelectControlled from './components/SelectControlled';
import { useCreateDashboard, useUpdateDashboard } from './dashboard';
import { withAppNav } from './layout';
import { useSources } from './source';
// The schema for the JSON data we expect to receive
const InputSchema = DashboardTemplateSchema;
type Input = z.infer<typeof InputSchema>;
function FileSelection({
onComplete,
}: {
onComplete: (input: Input | null) => void;
}) {
// The schema for the form data we expect to receive
const FormSchema = z.object({ file: z.instanceof(File).nullable() });
type FormValues = z.infer<typeof FormSchema>;
const [error, setError] = useState<{
message: string;
details?: string;
} | null>(null);
const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false);
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
});
const onSubmit = async ({ file }: FormValues) => {
setError(null);
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const parsed = InputSchema.parse(data); // throws if invalid
onComplete(parsed);
} catch (e: any) {
onComplete(null);
setError({
message: 'Failed to Import Dashboard',
details: e?.message ?? 'Failed to parse/validate JSON',
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<Controller
name="file"
control={control}
render={({ field }) => (
<Dropzone
onDrop={files => {
field.onChange(files[0]);
handleSubmit(onSubmit)();
}}
onReject={() =>
setError({ message: 'Invalid File Type or Size' })
}
maxSize={5 * 1024 ** 2}
maxFiles={1}
accept={['application/json']}
>
<Group
justify="center"
gap="xl"
mih={150}
style={{ pointerEvents: 'none' }}
>
<Dropzone.Accept>
<IconUpload
size={52}
color="var(--mantine-color-green-4)"
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX
size={52}
color="var(--mantine-color-red-6)"
stroke={1.5}
/>
</Dropzone.Reject>
<Dropzone.Idle>
<IconFile
size={52}
color="var(--mantine-color-dimmed)"
stroke={1.5}
/>
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Import Dashboard
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Drag and drop a JSON file here, or click to select from your
computer.
</Text>
</div>
</Group>
</Dropzone>
)}
/>
{error && (
<div>
<Text c="red">{error.message}</Text>
{error.details && (
<>
<Button
variant="transparent"
onClick={toggleErrorDetails}
px={0}
>
<Group c="red" gap={0} align="center">
<IconChevronRight
size="16px"
style={{
transition: 'transform 0.2s ease-in-out',
transform: errorDetails
? 'rotate(90deg)'
: 'rotate(0deg)',
}}
/>
{errorDetails ? 'Hide Details' : 'Show Details'}
</Group>
</Button>
<Collapse in={errorDetails}>
<Text c="red">{error.details}</Text>
</Collapse>
</>
)}
</div>
)}
</Stack>
</form>
);
}
const SourceResolutionForm = z.object({
dashboardName: z.string().min(1),
sourceMappings: z.array(z.string()),
});
type SourceResolutionFormValues = z.infer<typeof SourceResolutionForm>;
function Mapping({ input }: { input: Input }) {
const router = useRouter();
const { data: sources } = useSources();
const [dashboardId] = useQueryParam('dashboardId', StringParam);
const { handleSubmit, getFieldState, control, setValue, watch } =
useForm<SourceResolutionFormValues>({
resolver: zodResolver(SourceResolutionForm),
defaultValues: {
dashboardName: input.name,
sourceMappings: input.tiles.map(() => undefined),
},
});
// When the inputs change, reset the form
useEffect(() => {
if (!input || !sources) return;
const sourceMappings = input.tiles.map(tile => {
// find matching source name
const match = sources.find(
source =>
source.name.toLowerCase() === tile.config.source.toLowerCase(),
);
return match?.id || '';
});
setValue('sourceMappings', sourceMappings);
}, [setValue, sources, input]);
const isUpdatingRef = useRef(false);
watch((a, { name }) => {
if (isUpdatingRef.current) return;
if (!a.sourceMappings || !input.tiles) return;
const [, inputIdx] = name?.split('.') || [];
if (!inputIdx) return;
const idx = Number(inputIdx);
const inputTile = input.tiles[idx];
if (!inputTile) return;
const sourceId = a.sourceMappings[idx] ?? '';
const matching = input.tiles.reduce(
(acc, tile, idx) => {
if (tile.config.source === inputTile.config.source) {
acc.push({
...tile,
index: idx,
});
}
return acc;
},
[] as Input['tiles'] & { index: number }[],
);
isUpdatingRef.current = true;
for (const tile of matching) {
const key = `sourceMappings.${tile.index}` as const;
const fieldState = getFieldState(key);
// Only set if the field has not been modified
if (!fieldState.isDirty) {
setValue(key, sourceId, {
shouldValidate: true,
});
}
}
isUpdatingRef.current = false;
});
const createDashboard = useCreateDashboard();
const updateDashboard = useUpdateDashboard();
const onSubmit = async (data: SourceResolutionFormValues) => {
try {
// Zip the source mappings with the input tiles
const zippedTiles = input.tiles.map((tile, idx) => {
const source = sources?.find(
source => source.id === data.sourceMappings[idx],
);
return {
...tile,
config: {
...tile.config,
source: source!.id,
},
};
});
// Format for server
const output = convertToDashboardDocument({
...input,
tiles: zippedTiles,
name: data.dashboardName,
});
let _dashboardId = dashboardId;
if (_dashboardId) {
await updateDashboard.mutateAsync({
...output,
id: _dashboardId,
});
} else {
const result = await createDashboard.mutateAsync(output);
_dashboardId = result.id;
}
// Redirect
notifications.show({
color: 'green',
message: 'Import Successful!',
});
router.push(`/dashboards/${_dashboardId}`);
} catch {
notifications.show({
color: 'red',
message: 'Something went wrong. Please try again.',
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<Text fw={500} size="sm">
Step 2: Map Data
</Text>
<Controller
name="dashboardName"
control={control}
render={({ field, formState }) => (
<TextInput
label="Dashboard Name"
{...field}
error={formState.errors.dashboardName?.message}
/>
)}
/>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Input Source Name</Table.Th>
<Table.Th>Mapped Source Name</Table.Th>
</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>{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>
))}
</Table.Tbody>
</Table>
{createDashboard.isError && (
<Text c="red">{createDashboard.error.toString()}</Text>
)}
<Button type="submit" loading={createDashboard.isPending}>
Finish Import
</Button>
</Stack>
</form>
);
}
function DBDashboardImportPage() {
const [input, setInput] = useState<Input | null>(null);
return (
<div>
<Head>
<title>Create a Dashboard - HyperDX</title>
</Head>
<PageHeader>
<div>Create Dashboard &gt; Import Dashboard</div>
</PageHeader>
<div>
<Container>
<Stack gap="lg" mt="xl">
<FileSelection
onComplete={i => {
setInput(i);
}}
/>
{input && <Mapping input={input} />}
</Stack>
</Container>
</div>
</div>
);
}
const DBDashboardImportPageDynamic = dynamic(
async () => DBDashboardImportPage,
{
ssr: false,
},
);
// @ts-ignore
DBDashboardImportPageDynamic.getLayout = withAppNav;
export default DBDashboardImportPageDynamic;

View file

@ -18,7 +18,7 @@ import RGL, { WidthProvider } from 'react-grid-layout';
import { Controller, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { TableConnection } from '@hyperdx/common-utils/dist/metadata';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import { AlertState, TSourceUnion } from '@hyperdx/common-utils/dist/types';
import {
ChartConfigWithDateRange,
DisplayType,
@ -27,6 +27,7 @@ import {
SearchConditionLanguage,
SQLInterval,
} from '@hyperdx/common-utils/dist/types';
import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/utils';
import {
ActionIcon,
Badge,
@ -494,6 +495,19 @@ function DashboardName({
);
}
// Download an object to users computer as JSON using specified name
function downloadObjectAsJson(object: object, fileName = 'output') {
const dataStr =
'data:text/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(object));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute('href', dataStr);
downloadAnchorNode.setAttribute('download', fileName + '.json');
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const confirm = useConfirm();
@ -788,6 +802,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [isSaving, setIsSaving] = useState(false);
const hasTiles = dashboard && dashboard.tiles.length > 0;
return (
<Box p="sm">
<Head>
@ -890,8 +906,47 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</Menu.Target>
<Menu.Dropdown>
{hasTiles && (
<Menu.Item
leftSection={<i className="bi bi-download" />}
onClick={() => {
if (!sources || !dashboard) {
notifications.show({
color: 'red',
message: 'Export Failed',
});
return;
}
downloadObjectAsJson(
convertToDashboardTemplate(
dashboard,
// TODO: fix this type issue
sources as TSourceUnion[],
),
dashboard?.name,
);
}}
>
Export Dashboard
</Menu.Item>
)}
<Menu.Item
leftSection={<i className="bi bi-upload" />}
onClick={() => {
if (dashboard && !dashboard.tiles.length) {
router.push(
`/dashboards/import?dashboardId=${dashboard.id}`,
);
} else {
router.push('/dashboards/import');
}
}}
>
{hasTiles ? 'Import New Dashboard' : 'Import Dashboard'}
</Menu.Item>
<Menu.Item
leftSection={<i className="bi bi-trash-fill" />}
color="red"
onClick={() =>
deleteDashboard.mutate(dashboard?.id ?? '', {
onSuccess: () => {

View file

@ -429,25 +429,26 @@ export type ChartConfigWithOptDateRange = Omit<
timestampValueExpression?: string;
} & Partial<DateRange>;
export const SavedChartConfigSchema = z.intersection(
z.intersection(
z.object({
name: z.string(),
source: z.string(),
alert: z.union([
AlertBaseSchema.optional(),
ChartAlertBaseSchema.optional(),
]),
}),
export const SavedChartConfigSchema = z
.object({
name: z.string(),
source: z.string(),
alert: z.union([
AlertBaseSchema.optional(),
ChartAlertBaseSchema.optional(),
]),
})
.extend(
_ChartConfigSchema.omit({
connection: true,
timestampValueExpression: true,
}),
),
SelectSQLStatementSchema.omit({
from: true,
}),
);
}).shape,
)
.extend(
SelectSQLStatementSchema.omit({
from: true,
}).shape,
);
export type SavedChartConfig = z.infer<typeof SavedChartConfigSchema>;
@ -459,6 +460,9 @@ export const TileSchema = z.object({
h: z.number(),
config: SavedChartConfigSchema,
});
export const TileTemplateSchema = TileSchema.extend({
config: TileSchema.shape.config.omit({ alert: true }),
});
export type Tile = z.infer<typeof TileSchema>;
@ -468,8 +472,15 @@ export const DashboardSchema = z.object({
tiles: z.array(TileSchema),
tags: z.array(z.string()),
});
export const DashboardWithoutIdSchema = DashboardSchema.omit({ id: true });
export type DashboardWithoutId = z.infer<typeof DashboardWithoutIdSchema>;
export const DashboardTemplateSchema = DashboardWithoutIdSchema.omit({
tags: true,
}).extend({
version: z.string().min(1),
tiles: z.array(TileTemplateSchema),
});
export const ConnectionSchema = z.object({
id: z.string(),

View file

@ -1,8 +1,17 @@
// Port from ChartUtils + source.ts
import { add as fnsAdd, format as fnsFormat } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { z } from 'zod';
import type { ChartConfigWithDateRange, SQLInterval } from '@/types';
import {
ChartConfigWithDateRange,
DashboardSchema,
DashboardTemplateSchema,
DashboardWithoutId,
SQLInterval,
TileTemplateSchema,
TSourceUnion,
} from '@/types';
export const isBrowser: boolean =
typeof window !== 'undefined' && typeof window.document !== 'undefined';
@ -299,6 +308,59 @@ export const formatDate = (
: fnsFormat(date, formatStr);
};
type Dashboard = z.infer<typeof DashboardSchema>;
type DashboardTemplate = z.infer<typeof DashboardTemplateSchema>;
type TileTemplate = z.infer<typeof TileTemplateSchema>;
export function convertToDashboardTemplate(
input: Dashboard,
sources: TSourceUnion[],
): DashboardTemplate {
const output: DashboardTemplate = {
version: '0.1.0',
name: input.name,
tiles: [],
};
const convertToTileTemplate = (
input: Dashboard['tiles'][0],
sources: TSourceUnion[],
): TileTemplate => {
const tile = TileTemplateSchema.strip().parse(structuredClone(input));
// Extract name from source or default to '' if not found
tile.config.source = (
sources.find(source => source.id === tile.config.source) ?? { name: '' }
).name;
return tile;
};
for (const tile of input.tiles) {
output.tiles.push(convertToTileTemplate(tile, sources));
}
return output;
}
export function convertToDashboardDocument(
input: DashboardTemplate,
): DashboardWithoutId {
const output: DashboardWithoutId = {
name: input.name,
tiles: [],
tags: [],
};
// expecting that input.tiles[0-n].config.source fields are already converted to ids
const convertToTileDocument = (
input: TileTemplate,
): DashboardWithoutId['tiles'][0] => {
return structuredClone(input);
};
for (const tile of input.tiles) {
output.tiles.push(convertToTileDocument(tile));
}
return output;
}
export const getFirstOrderingItem = (
orderBy: ChartConfigWithDateRange['orderBy'],
) => {

View file

@ -4530,6 +4530,7 @@ __metadata:
"@lezer/highlight": "npm:^1.2.0"
"@mantine/core": "npm:7.9.2"
"@mantine/dates": "npm:^7.11.2"
"@mantine/dropzone": "npm:^8.3.1"
"@mantine/form": "npm:^7.11.2"
"@mantine/hooks": "npm:7.9.2"
"@mantine/notifications": "npm:^7.9.2"
@ -5595,6 +5596,20 @@ __metadata:
languageName: node
linkType: hard
"@mantine/dropzone@npm:^8.3.1":
version: 8.3.2
resolution: "@mantine/dropzone@npm:8.3.2"
dependencies:
react-dropzone: "npm:14.3.8"
peerDependencies:
"@mantine/core": 8.3.2
"@mantine/hooks": 8.3.2
react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x
checksum: 10c0/db80f42256c48959948862f6948321d716226085cf478086a6554382f521a8d4c8126d4195f4c15be929d0f88ce3f497b979731c6567228e483f7a815de0ae3d
languageName: node
linkType: hard
"@mantine/form@npm:^7.11.2":
version: 7.11.2
resolution: "@mantine/form@npm:7.11.2"
@ -12042,6 +12057,13 @@ __metadata:
languageName: node
linkType: hard
"attr-accept@npm:^2.2.4":
version: 2.2.5
resolution: "attr-accept@npm:2.2.5"
checksum: 10c0/9b4cb82213925cab2d568f71b3f1c7a7778f9192829aac39a281e5418cd00c04a88f873eb89f187e0bf786fa34f8d52936f178e62cbefb9254d57ecd88ada99b
languageName: node
linkType: hard
"available-typed-arrays@npm:^1.0.5":
version: 1.0.5
resolution: "available-typed-arrays@npm:1.0.5"
@ -16542,6 +16564,15 @@ __metadata:
languageName: node
linkType: hard
"file-selector@npm:^2.1.0":
version: 2.1.2
resolution: "file-selector@npm:2.1.2"
dependencies:
tslib: "npm:^2.7.0"
checksum: 10c0/fe827e0e95410aacfcc3eabc38c29cc36055257f03c1c06b631a2b5af9730c142ad2c52f5d64724d02231709617bda984701f52bd1f4b7aca50fb6585a27c1d2
languageName: node
linkType: hard
"file-system-cache@npm:2.3.0":
version: 2.3.0
resolution: "file-system-cache@npm:2.3.0"
@ -24545,6 +24576,19 @@ __metadata:
languageName: node
linkType: hard
"react-dropzone@npm:14.3.8":
version: 14.3.8
resolution: "react-dropzone@npm:14.3.8"
dependencies:
attr-accept: "npm:^2.2.4"
file-selector: "npm:^2.1.0"
prop-types: "npm:^15.8.1"
peerDependencies:
react: ">= 16.8 || 18.0.0"
checksum: 10c0/e17b1832783cda7b8824fe9370e99185d1abbdd5e4980b2985d6321c5768c8de18ff7b9ad550c809ee9743269dea608ff74d5208062754ce8377ad022897b278
languageName: node
linkType: hard
"react-element-to-jsx-string@npm:^15.0.0":
version: 15.0.0
resolution: "react-element-to-jsx-string@npm:15.0.0"
@ -28128,6 +28172,13 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.7.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
languageName: node
linkType: hard
"tsup@npm:^8.4.0":
version: 8.4.0
resolution: "tsup@npm:8.4.0"