mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
816f90a392
commit
24314a9605
10 changed files with 606 additions and 19 deletions
6
.changeset/smooth-ladybugs-care.md
Normal file
6
.changeset/smooth-ladybugs-care.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
add dashboard import/export functionality
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
3
packages/app/pages/dashboards/import.tsx
Normal file
3
packages/app/pages/dashboards/import.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import DBDashboardImportPage from '@/DBDashboardImportPage';
|
||||
|
||||
export default DBDashboardImportPage;
|
||||
396
packages/app/src/DBDashboardImportPage.tsx
Normal file
396
packages/app/src/DBDashboardImportPage.tsx
Normal 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 > 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;
|
||||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
) => {
|
||||
|
|
|
|||
51
yarn.lock
51
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue