feat: Add dashboard template gallery (#2010)

## Summary

This PR adds a gallery of importable dashboard templates to the dashboards page. The existing Dashboard import functionality is modified to support importing dashboard templates which are included in the app source code bundle.

### Screenshots or video

https://github.com/user-attachments/assets/eae37214-f012-44dd-83ef-086749846260

### How to test locally or on Vercel

This can be tested as shown above in the preview environment.

### References



- Linear Issue: Closes HDX-3661 Closes HDX-3814
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-01 13:33:07 -04:00 committed by GitHub
parent 0cc1295d36
commit 518bda7d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1906 additions and 19 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Add dashboard template gallery

View file

@ -9,3 +9,4 @@ OTEL_SERVICE_NAME="hdx-oss-dev-app"
PORT=${HYPERDX_APP_PORT}
NODE_OPTIONS="--max-http-header-size=131072"
NEXT_PUBLIC_HYPERDX_BASE_PATH=
NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PORT:-4318}"

View file

@ -0,0 +1,3 @@
import DashboardTemplatesPage from '@/components/Dashboards/DashboardTemplatesPage';
export default DashboardTemplatesPage;

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
@ -10,6 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
type DashboardTemplate,
DashboardTemplateSchema,
SavedChartConfig,
} from '@hyperdx/common-utils/dist/types';
@ -20,9 +21,10 @@ import {
Collapse,
Container,
Group,
Input,
Loader,
Stack,
Table,
TagsInput,
Text,
TextInput,
} from '@mantine/core';
@ -36,22 +38,19 @@ import {
IconX,
} from '@tabler/icons-react';
import { PageHeader } from './components/PageHeader';
import SelectControlled from './components/SelectControlled';
import { useBrandDisplayName } from './theme/ThemeProvider';
import api from './api';
import { useConnections } from './connection';
import { useCreateDashboard, useUpdateDashboard } from './dashboard';
import { getDashboardTemplate } from './dashboardTemplates';
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;
onComplete: (input: DashboardTemplate | null) => void;
}) {
// The schema for the form data we expect to receive
const FormSchema = z.object({ file: z.instanceof(File).nullable() });
@ -79,7 +78,7 @@ function FileSelection({
try {
const text = await file.text();
const data = JSON.parse(text);
const parsed = InputSchema.parse(data); // throws if invalid
const parsed = DashboardTemplateSchema.parse(data); // throws if invalid
onComplete(parsed);
} catch (e: any) {
onComplete(null);
@ -188,6 +187,7 @@ function FileSelection({
const MappingForm = z.object({
dashboardName: z.string().min(1),
tags: z.array(z.string()),
sourceMappings: z.array(z.string()),
connectionMappings: z.array(z.string()),
filterSourceMappings: z.array(z.string()).optional(),
@ -195,10 +195,11 @@ const MappingForm = z.object({
type MappingFormValues = z.infer<typeof MappingForm>;
function Mapping({ input }: { input: Input }) {
function Mapping({ input }: { input: DashboardTemplate }) {
const router = useRouter();
const { data: sources } = useSources();
const { data: connections } = useConnections();
const { data: existingTags } = api.useTags();
const [dashboardId] = useQueryParam('dashboardId', StringParam);
const { handleSubmit, getFieldState, control, setValue } =
@ -206,6 +207,7 @@ function Mapping({ input }: { input: Input }) {
resolver: zodResolver(MappingForm),
defaultValues: {
dashboardName: input.name,
tags: input.tags ?? [],
sourceMappings: input.tiles.map(() => ''),
connectionMappings: input.tiles.map(() => ''),
},
@ -381,6 +383,7 @@ function Mapping({ input }: { input: Input }) {
tiles: zippedTiles,
filters: zippedFilters,
name: data.dashboardName,
tags: data.tags,
});
let _dashboardId = dashboardId;
if (_dashboardId) {
@ -423,6 +426,18 @@ function Mapping({ input }: { input: Input }) {
/>
)}
/>
<Controller
name="tags"
control={control}
render={({ field }) => (
<TagsInput
label="Tags"
placeholder="Add tags"
data={existingTags?.data ?? []}
{...field}
/>
)}
/>
<Table>
<Table.Thead>
<Table.Tr>
@ -496,7 +511,7 @@ function Mapping({ input }: { input: Input }) {
{createDashboard.isError && (
<Text c="red">{createDashboard.error.toString()}</Text>
)}
<Button type="submit" loading={createDashboard.isPending}>
<Button type="submit" loading={createDashboard.isPending} mb="md">
Finish Import
</Button>
</Stack>
@ -506,7 +521,21 @@ function Mapping({ input }: { input: Input }) {
function DBDashboardImportPage() {
const brandName = useBrandDisplayName();
const [input, setInput] = useState<Input | null>(null);
const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const templateName = router.query.template as string | undefined;
const isTemplate = !!templateName;
const isLoadingRoute = !router.isReady;
const templateInput = useMemo(
() => (templateName ? getDashboardTemplate(templateName) : undefined),
[templateName],
);
const [fileInput, setFileInput] = useState<DashboardTemplate | null>(null);
const input = templateInput ?? fileInput;
const isTemplateNotFound = isTemplate && !isLoadingRoute && !templateInput;
return (
<div>
@ -517,6 +546,16 @@ function DBDashboardImportPage() {
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
{isTemplate && (
<Anchor
component={Link}
href="/dashboards/templates"
fz="sm"
c="dimmed"
>
Templates
</Anchor>
)}
<Text fz="sm" c="dimmed">
Import
</Text>
@ -524,11 +563,26 @@ function DBDashboardImportPage() {
<div>
<Container>
<Stack gap="lg" mt="xl">
<FileSelection
onComplete={i => {
setInput(i);
}}
/>
{isLoadingRoute ? (
<Loader mx="auto" />
) : isTemplateNotFound ? (
<Stack align="center" gap="sm" py="xl">
<Text ta="center">Oops! We couldn't find that template.</Text>
<Text ta="center">
Try{' '}
<Anchor component={Link} href="/dashboards/templates">
browsing available templates
</Anchor>
.
</Text>
</Stack>
) : !isTemplate ? (
<FileSelection
onComplete={i => {
setFileInput(i);
}}
/>
) : null}
{input && <Mapping input={input} />}
</Stack>
</Container>

View file

@ -0,0 +1,33 @@
import fs from 'fs';
import path from 'path';
import { DashboardTemplateSchema } from '@hyperdx/common-utils/dist/types';
const TEMPLATES_DIR = path.resolve(__dirname, '../dashboardTemplates');
const jsonFiles = fs
.readdirSync(TEMPLATES_DIR)
.filter(f => f.endsWith('.json'));
describe('dashboard templates', () => {
it('should have at least one template', () => {
expect(jsonFiles.length).toBeGreaterThan(0);
});
it.each(jsonFiles)('%s should be a valid DashboardTemplate', file => {
const raw = fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf-8');
const json = JSON.parse(raw);
const result = DashboardTemplateSchema.safeParse(json);
if (!result.success) {
throw new Error(
`${file} failed validation:\n${result.error.issues.map(i => ` - ${i.path.join('.')}: ${i.message}`).join('\n')}`,
);
}
});
it.each(jsonFiles)('%s should have a description', file => {
const raw = fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf-8');
const json = JSON.parse(raw);
expect(json.description).toBeTruthy();
});
});

View file

@ -727,6 +727,23 @@ describe('validateChartForm', () => {
).toHaveLength(0);
});
it('does not validate valueExpression for metric sources', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeForm({
source: 'source-metric',
series: [{ ...seriesItem, aggFn: 'sum', valueExpression: '' }],
}),
metricSource,
setError,
);
expect(
errors.filter(
e => typeof e.path === 'string' && e.path.includes('valueExpression'),
),
).toHaveLength(0);
});
it('does not validate valueExpression for Markdown displayType', () => {
const setError = jest.fn();
const errors = validateChartForm(

View file

@ -214,6 +214,7 @@ export const validateChartForm = (
if (
!isRawSqlChart &&
Array.isArray(form.series) &&
source?.kind !== SourceKind.Metric &&
form.displayType !== DisplayType.Markdown &&
form.displayType !== DisplayType.Search
) {

View file

@ -0,0 +1,106 @@
import { useMemo } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {
Anchor,
Breadcrumbs,
Button,
Card,
Container,
SimpleGrid,
Stack,
Text,
} from '@mantine/core';
import { IconUpload } from '@tabler/icons-react';
import { DASHBOARD_TEMPLATES } from '@/dashboardTemplates';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import { withAppNav } from '../../layout';
export default function DashboardTemplatesPage() {
const brandName = useBrandDisplayName();
const templatesByTag = useMemo(() => {
const groups = new Map<string, typeof DASHBOARD_TEMPLATES>();
for (const t of DASHBOARD_TEMPLATES) {
const tags = t.tags.length > 0 ? t.tags : ['Other'];
for (const tag of tags) {
const group = groups.get(tag) ?? [];
group.push(t);
groups.set(tag, group);
}
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(
([tag, items]) =>
[
tag,
items.slice().sort((a, b) => a.name.localeCompare(b.name)),
] as const,
);
}, []);
return (
<div data-testid="dashboard-templates-page">
<Head>
<title>Dashboard Templates - {brandName}</title>
</Head>
<Breadcrumbs my="lg" ms="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
Templates
</Text>
</Breadcrumbs>
<Container maw={1200} py="lg" px="lg">
<Stack gap="xl">
{templatesByTag.map(([tag, templates]) => (
<div key={tag}>
<Text fw={500} size="sm" c="dimmed" mb="sm">
{tag}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{templates.map(t => (
<Card key={t.id} withBorder padding="lg" radius="sm">
<Stack justify="space-between" h="100%">
<Stack>
<Text
fw={500}
lineClamp={1}
mb="xs"
style={{ minWidth: 0 }}
title={t.name}
>
{t.name}
</Text>
<Text size="sm" c="dimmed">
{t.description}
</Text>
</Stack>
<Button
component={Link}
href={`/dashboards/import?template=${t.id}`}
variant="secondary"
leftSection={<IconUpload size={16} />}
mt="md"
size="xs"
data-testid={`import-template-${t.id}`}
>
Import
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
</div>
))}
</Stack>
</Container>
</div>
);
}
DashboardTemplatesPage.getLayout = withAppNav;

View file

@ -5,6 +5,7 @@ import Router from 'next/router';
import { useQueryState } from 'nuqs';
import {
ActionIcon,
Anchor,
Box,
Button,
Container,
@ -173,11 +174,16 @@ export default function DashboardsListPage() {
<Text fw={500} size="sm" c="dimmed" mb="sm">
Preset Dashboards
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="xl">
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="sm">
{PRESET_DASHBOARDS.map(p => (
<ListingCard key={p.href} {...p} />
))}
</SimpleGrid>
<Text ta="right" mb="sm">
<Anchor component={Link} href="/dashboards/templates" fz="sm">
Browse dashboard templates &rarr;
</Anchor>
</Text>
{favoritedDashboards.length > 0 && (
<>

View file

@ -0,0 +1,357 @@
{
"version": "0.1.0",
"name": ".NET Runtime Metrics",
"description": "Garbage collection, heap fragmentation, exception, thread pool, and CPU metrics for .NET v9+ applications with the OpenTelemetry.Instrumentation.Runtime package",
"tags": [
"OTel Runtime Metrics"
],
"tiles": [
{
"id": "6d4b7e",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "GC Heap Size",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.gc.last_collection.heap.size",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "07f54d",
"x": 6,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Exceptions",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.exceptions",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 0,
"thousandSeparated": true
}
}
},
{
"id": "c9bc67",
"x": 12,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "GC Pause (s)",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.gc.pause.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 3,
"thousandSeparated": true
}
}
},
{
"id": "334016",
"x": 18,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "ThreadPool Items",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.thread_pool.work_item.count",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 0,
"thousandSeparated": true
}
}
},
{
"id": "98e92c",
"x": 0,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "GC Collections by Generation",
"source": "Metrics",
"displayType": "stacked_bar",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.gc.collections",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['gc.heap.generation']"
}
},
{
"id": "f6c44a",
"x": 12,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "GC Pause Time (s)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.gc.pause.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 3,
"thousandSeparated": true
}
}
},
{
"id": "e54085",
"x": 0,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "Last Collection Heap Size (by gen)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "dotnet.gc.last_collection.heap.size",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['gc.heap.generation']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "34979f",
"x": 12,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "Heap Fragmentation (by gen)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "dotnet.gc.last_collection.heap.fragmentation.size",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['gc.heap.generation']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "6649b7",
"x": 0,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "Exception Rate",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.exceptions",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql"
}
},
{
"id": "3a8add",
"x": 12,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "Allocation Rate",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.gc.heap.total_allocated",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "fdfdc8",
"x": 0,
"y": 24,
"w": 12,
"h": 7,
"config": {
"name": "CPU Cores Used",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.process.cpu.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql"
}
},
{
"id": "ebe6dc",
"x": 12,
"y": 24,
"w": 12,
"h": 7,
"config": {
"name": "ThreadPool Throughput & Lock Contention",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.thread_pool.work_item.count",
"metricType": "sum",
"alias": "work items"
},
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "dotnet.monitor.lock_contentions",
"metricType": "sum",
"alias": "contentions"
}
],
"where": "",
"whereLanguage": "sql"
}
}
],
"filters": [
{
"id": "svc-filter-001",
"type": "QUERY_EXPRESSION",
"name": "ServiceName",
"expression": "ServiceName",
"source": "Metrics",
"sourceMetricType": "sum",
"where": "ResourceAttributes['telemetry.sdk.language'] = 'dotnet'",
"whereLanguage": "sql"
}
]
}

View file

@ -0,0 +1,347 @@
{
"version": "0.1.0",
"name": "Go Runtime Metrics",
"description": "Memory usage, allocations, GC targets, CPU utilization, and goroutine metrics for Go applications with the OTel Runtime (v0.62+) and Host Metrics instrumentations",
"tags": [
"OTel Runtime Metrics"
],
"tiles": [
{
"id": "109853",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Memory Used",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "go.memory.used",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "e6a3df",
"x": 6,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Alloc Rate",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "go.memory.allocated",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "40f62f",
"x": 12,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "GC Heap Target",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.memory.gc.goal",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "2029f0",
"x": 18,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "process.cpu.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "bbde66",
"x": 0,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Memory: Used vs Limit vs GC Target",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "go.memory.used",
"metricType": "sum",
"alias": "used"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.memory.limit",
"metricType": "sum",
"alias": "limit"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.memory.gc.goal",
"metricType": "sum",
"alias": "gc target"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "deb616",
"x": 12,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Memory Allocated (rate)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "go.memory.allocated",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "432607",
"x": 0,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "process.cpu.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "08224d",
"x": 12,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "CPU Cores Used",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "process.cpu.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql"
}
},
{
"id": "fae8d7",
"x": 0,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "Object Allocations",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "go.memory.allocations",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 0,
"thousandSeparated": true
}
}
},
{
"id": "545a68",
"x": 12,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "Memory Used by Type",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.memory.used",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['go.memory.type']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "c0fc04",
"x": 0,
"y": 24,
"w": 12,
"h": 7,
"config": {
"name": "GC Target (%) & GOMAXPROCS",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.config.gogc",
"metricType": "sum",
"alias": "GC target %"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "go.processor.limit",
"metricType": "sum",
"alias": "GOMAXPROCS"
}
],
"where": "",
"whereLanguage": "sql"
}
}
],
"filters": [
{
"id": "svc-filter-001",
"type": "QUERY_EXPRESSION",
"name": "ServiceName",
"expression": "ServiceName",
"source": "Metrics",
"sourceMetricType": "sum",
"where": "ResourceAttributes['telemetry.sdk.language'] = 'go'",
"whereLanguage": "sql"
}
]
}

View file

@ -0,0 +1,52 @@
import {
type DashboardTemplate,
DashboardTemplateSchema,
} from '@hyperdx/common-utils/dist/types';
import dotnetRuntime from './dotnet-runtime.json';
import goRuntime from './go-runtime.json';
import jvmRuntimeMetrics from './jvm-runtime-metrics.json';
import nodejsRuntime from './nodejs-runtime.json';
function parseTemplate(
id: string,
json: unknown,
): DashboardTemplate | undefined {
const result = DashboardTemplateSchema.safeParse(json);
if (!result.success) {
// This should not happen, we have a unit test to catch invalid templates.
console.error(`Error parsing dashboard template "${id}":`, result.error);
return undefined;
}
return result.data;
}
const templates: Record<string, unknown> = {
'dotnet-runtime': dotnetRuntime,
'go-runtime': goRuntime,
'jvm-runtime-metrics': jvmRuntimeMetrics,
'nodejs-runtime': nodejsRuntime,
};
export const DASHBOARD_TEMPLATES = Object.entries(templates)
.map(([id, template]) => {
const parsedTemplate = parseTemplate(id, template);
if (!parsedTemplate) {
return undefined;
}
return {
id,
name: parsedTemplate.name,
description: parsedTemplate.description ?? '',
tags: parsedTemplate.tags ?? [],
};
})
.filter(t => t !== undefined);
export function getDashboardTemplate(
id: string,
): DashboardTemplate | undefined {
const json = templates[id];
return parseTemplate(id, json);
}

View file

@ -0,0 +1,389 @@
{
"version": "0.1.0",
"name": "JVM Runtime Metrics",
"description": "Heap memory, CPU utilization, threads, and GC metrics for JVM applications with the OTel Java Agent v2+ wth JVM v17+",
"tags": [
"OTel Runtime Metrics"
],
"tiles": [
{
"id": "dd919b",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Heap Used",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.used",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "c847ba",
"x": 6,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.cpu.recent_utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "777a97",
"x": 12,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Thread Count",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "jvm.thread.count",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 0,
"thousandSeparated": true
}
}
},
{
"id": "1b82ef",
"x": 18,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "CPU Count",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.cpu.count",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 0,
"thousandSeparated": true
}
}
},
{
"id": "6363a5",
"x": 0,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Heap: Used vs Committed vs Limit",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.used",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql",
"alias": "used"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.committed",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql",
"alias": "committed"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.limit",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql",
"alias": "limit"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "5abddd",
"x": 12,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Retained Heap After GC (by pool)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.used_after_last_gc",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['jvm.memory.pool.name']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "c4abeb",
"x": 0,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "Heap Memory Used by Pool",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.used",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'heap'",
"aggConditionLanguage": "sql"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['jvm.memory.pool.name']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "26aa01",
"x": 12,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "Non-Heap Memory (metaspace)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.memory.used",
"metricType": "sum",
"aggCondition": "Attributes['jvm.memory.type'] = 'non_heap'",
"aggConditionLanguage": "sql"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['jvm.memory.pool.name']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "ea6e2d",
"x": 0,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.cpu.recent_utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "bf01ef",
"x": 12,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "CPU Cores Used",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "jvm.cpu.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql"
}
},
{
"id": "f90a9e",
"x": 0,
"y": 24,
"w": 12,
"h": 7,
"config": {
"name": "Threads by State",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.thread.count",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['jvm.thread.state']"
}
},
{
"id": "17f41d",
"x": 12,
"y": 24,
"w": 12,
"h": 7,
"config": {
"name": "Loaded Classes",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "jvm.class.count",
"metricType": "sum",
"alias": "current"
},
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "jvm.class.loaded",
"metricType": "sum",
"alias": "loaded/s"
}
],
"where": "",
"whereLanguage": "sql"
}
}
],
"filters": [
{
"id": "svc-filter-001",
"type": "QUERY_EXPRESSION",
"name": "ServiceName",
"expression": "ServiceName",
"source": "Metrics",
"sourceMetricType": "sum",
"where": "ResourceAttributes['telemetry.sdk.language'] = 'java'",
"whereLanguage": "sql"
}
]
}

View file

@ -0,0 +1,324 @@
{
"version": "0.1.0",
"name": "Node.js Runtime Metrics",
"description": "Event loop delay, heap usage, CPU utilization, and V8 memory for Node.js applications with OTel Runtime and Host Metrics instrumentations",
"tags": [
"OTel Runtime Metrics"
],
"tiles": [
{
"id": "55ef66",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "EL Delay p99 (ms)",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.delay.p99",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 1,
"thousandSeparated": true
}
}
},
{
"id": "31e132",
"x": 6,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Event Loop Util",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "52f4a5",
"x": 12,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "Heap Used",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "v8js.memory.heap.used",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "2029f0",
"x": 18,
"y": 0,
"w": 6,
"h": 3,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "number",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "process.cpu.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "f9dae7",
"x": 0,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Event Loop Delay Percentiles (ms)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.delay.p50",
"metricType": "gauge",
"alias": "p50"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.delay.p90",
"metricType": "gauge",
"alias": "p90"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.delay.p99",
"metricType": "gauge",
"alias": "p99"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "number",
"mantissa": 1,
"thousandSeparated": true
}
}
},
{
"id": "93c11e",
"x": 12,
"y": 3,
"w": 12,
"h": 7,
"config": {
"name": "Event Loop Utilization",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "nodejs.eventloop.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "7155d4",
"x": 0,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "V8 Heap: Used vs Limit",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "v8js.memory.heap.used",
"metricType": "gauge",
"alias": "used"
},
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "v8js.memory.heap.limit",
"metricType": "gauge",
"alias": "limit"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "c2ea50",
"x": 12,
"y": 10,
"w": 12,
"h": 7,
"config": {
"name": "V8 Heap Spaces (physical size)",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "v8js.memory.heap.space.physical_size",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"groupBy": "Attributes['v8js.heap.space.name']",
"numberFormat": {
"output": "byte",
"mantissa": 1
}
}
},
{
"id": "ea6e2d",
"x": 0,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "CPU Utilization",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "avg",
"valueExpression": "",
"metricName": "process.cpu.utilization",
"metricType": "gauge"
}
],
"where": "",
"whereLanguage": "sql",
"numberFormat": {
"output": "percent",
"mantissa": 1
}
}
},
{
"id": "a73b57",
"x": 12,
"y": 17,
"w": 12,
"h": 7,
"config": {
"name": "Event Loop Busy Time",
"source": "Metrics",
"displayType": "line",
"granularity": "auto",
"alignDateRangeToGranularity": true,
"select": [
{
"aggFn": "sum",
"valueExpression": "",
"metricName": "nodejs.eventloop.time",
"metricType": "sum"
}
],
"where": "",
"whereLanguage": "sql"
}
}
],
"filters": [
{
"id": "svc-filter-001",
"type": "QUERY_EXPRESSION",
"name": "ServiceName",
"expression": "ServiceName",
"source": "Metrics",
"sourceMetricType": "gauge",
"where": "ResourceAttributes['telemetry.sdk.language'] = 'nodejs'",
"whereLanguage": "sql"
}
]
}

View file

@ -0,0 +1,106 @@
import { DashboardImportPage } from '../page-objects/DashboardImportPage';
import { DashboardPage } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { expect, test } from '../utils/base-test';
import { DEFAULT_METRICS_SOURCE_NAME } from '../utils/constants';
test.describe('Dashboard Template Import', { tag: ['@dashboard'] }, () => {
let dashboardsListPage: DashboardsListPage;
let dashboardImportPage: DashboardImportPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboardsListPage = new DashboardsListPage(page);
dashboardImportPage = new DashboardImportPage(page);
dashboardPage = new DashboardPage(page);
});
test(
'should import a template from listing page through to new dashboard',
{ tag: '@full-stack' },
async ({ page }) => {
await test.step('Navigate to dashboard listing page and verify templates link', async () => {
await dashboardsListPage.goto();
await expect(dashboardsListPage.pageContainer).toBeVisible();
await expect(dashboardsListPage.browseTemplatesLink).toBeVisible();
});
await test.step('Navigate to templates page via Browse dashboard templates link', async () => {
await dashboardsListPage.clickBrowseTemplates();
await expect(page).toHaveURL(/\/dashboards\/templates/);
await expect(dashboardImportPage.templatesPageContainer).toBeVisible();
});
await test.step('Verify template cards are listed', async () => {
await expect(
dashboardImportPage.getTemplateImportButton('dotnet-runtime'),
).toBeVisible();
await expect(
dashboardImportPage.getTemplateImportButton('jvm-runtime-metrics'),
).toBeVisible();
});
await test.step('Click Import on the .NET Runtime Metrics template', async () => {
await dashboardImportPage.clickImportTemplate('dotnet-runtime');
await expect(page).toHaveURL(
/\/dashboards\/import\?template=dotnet-runtime/,
);
});
await test.step('Verify the import mapping page loaded correctly', async () => {
// File upload dropzone is not rendered in template mode
await expect(dashboardImportPage.fileUploadDropzone).toBeHidden();
// Step 2 mapping form is visible
await expect(dashboardImportPage.mappingStepHeading).toBeVisible();
// Dashboard name is pre-filled from the template
await expect(dashboardImportPage.dashboardNameInput).toHaveValue(
'.NET Runtime Metrics',
);
// A tile name from the .NET template is shown in the mapping table
await expect(page.getByText('GC Heap Size')).toBeVisible();
});
await test.step('Map the first source dropdown to E2E Metrics', async () => {
await dashboardImportPage.selectSourceMapping(
DEFAULT_METRICS_SOURCE_NAME,
0,
);
});
await test.step('Submit the import and verify success notification', async () => {
await dashboardImportPage.finishImportButton.click();
await expect(
dashboardImportPage.getImportSuccessNotification(),
).toBeVisible();
await page.waitForURL(/\/dashboards\/.+/);
});
await test.step('Verify the new dashboard has the correct name', async () => {
await expect(page).toHaveURL(/\/dashboards\/.+/);
await expect(
dashboardPage.getDashboardHeading('.NET Runtime Metrics'),
).toBeVisible();
});
},
);
test(
'should show error for invalid template name',
{ tag: '@full-stack' },
async () => {
await test.step('Navigate to import page with a nonexistent template param', async () => {
await dashboardImportPage.gotoImport('nonexistent-template');
});
await test.step('Verify template-not-found error and link to templates', async () => {
await expect(dashboardImportPage.templateNotFoundText).toBeVisible();
await expect(
dashboardImportPage.browseAvailableTemplatesLink,
).toBeVisible();
await expect(
dashboardImportPage.browseAvailableTemplatesLink,
).toHaveAttribute('href', '/dashboards/templates');
});
},
);
});

View file

@ -0,0 +1,70 @@
/**
* DashboardImportPage - Page object for dashboard template browsing and import
* Covers the /dashboards/templates page and the /dashboards/import page
*/
import { Locator, Page } from '@playwright/test';
export class DashboardImportPage {
readonly page: Page;
readonly templatesPageContainer: Locator;
readonly mappingStepHeading: Locator;
readonly dashboardNameInput: Locator;
readonly finishImportButton: Locator;
readonly fileUploadDropzone: Locator;
readonly templateNotFoundText: Locator;
readonly browseAvailableTemplatesLink: Locator;
constructor(page: Page) {
this.page = page;
this.templatesPageContainer = page.getByTestId('dashboard-templates-page');
this.mappingStepHeading = page.getByText('Step 2: Map Data');
this.dashboardNameInput = page.getByLabel('Dashboard Name');
this.finishImportButton = page.getByRole('button', {
name: 'Finish Import',
});
this.fileUploadDropzone = page.getByText('Drag and drop a JSON file here', {
exact: false,
});
this.templateNotFoundText = page.getByText(
"Oops! We couldn't find that template.",
);
this.browseAvailableTemplatesLink = page.getByRole('link', {
name: 'browsing available templates',
});
}
async gotoTemplates() {
await this.page.goto('/dashboards/templates', { waitUntil: 'networkidle' });
}
async gotoImport(templateId?: string) {
const url = templateId
? `/dashboards/import?template=${templateId}`
: '/dashboards/import';
await this.page.goto(url, { waitUntil: 'networkidle' });
}
getTemplateImportButton(templateId: string) {
return this.page.getByTestId(`import-template-${templateId}`);
}
async clickImportTemplate(templateId: string) {
await this.getTemplateImportButton(templateId).click();
await this.page.waitForURL(`**/dashboards/import?template=${templateId}`);
}
getSourceMappingSelect(index = 0) {
return this.page.getByPlaceholder('Select a source').nth(index);
}
async selectSourceMapping(sourceName: string, index = 0) {
await this.getSourceMappingSelect(index).click();
await this.page
.getByRole('option', { name: sourceName, exact: true })
.click();
}
getImportSuccessNotification() {
return this.page.getByText('Import Successful!');
}
}

View file

@ -14,6 +14,7 @@ export class DashboardsListPage {
readonly tempDashboardButton: Locator;
readonly gridViewButton: Locator;
readonly listViewButton: Locator;
readonly browseTemplatesLink: Locator;
private readonly emptyCreateDashboardButton: Locator;
private readonly emptyImportDashboardButton: Locator;
@ -29,6 +30,9 @@ export class DashboardsListPage {
this.tempDashboardButton = page.getByTestId('temp-dashboard-button');
this.gridViewButton = page.getByRole('button', { name: 'Grid view' });
this.listViewButton = page.getByRole('button', { name: 'List view' });
this.browseTemplatesLink = page.getByRole('link', {
name: /Browse dashboard templates/,
});
this.emptyCreateDashboardButton = page.getByTestId(
'empty-create-dashboard-button',
);
@ -42,6 +46,11 @@ export class DashboardsListPage {
await this.page.goto('/dashboards/list', { waitUntil: 'networkidle' });
}
async clickBrowseTemplates() {
await this.browseTemplatesLink.click();
await this.page.waitForURL('**/dashboards/templates');
}
async searchDashboards(query: string) {
await this.searchInput.fill(query);
}

View file

@ -544,6 +544,7 @@ describe('utils', () => {
expect(template).toEqual({
name: 'My Dashboard',
version: '0.1.0',
tags: ['tag1', 'tag2'],
tiles: [
{
id: 'tile1',
@ -665,6 +666,7 @@ describe('utils', () => {
expect(template).toEqual({
name: 'My Dashboard',
version: '0.1.0',
tags: ['tag1', 'tag2'],
tiles: [
{
id: 'tile1',

View file

@ -466,6 +466,7 @@ export function convertToDashboardTemplate(
const output: DashboardTemplate = {
version: '0.1.0',
name: input.name,
tags: input.tags.length > 0 ? input.tags : undefined,
tiles: [],
};
@ -530,7 +531,7 @@ export function convertToDashboardDocument(
const output: DashboardWithoutId = {
name: input.name,
tiles: [],
tags: [],
tags: input.tags ?? [],
};
// expecting that input.tiles[0-n].config.source fields are already converted to ids

View file

@ -764,9 +764,12 @@ export const DashboardTemplateSchema = DashboardWithoutIdSchema.omit({
tags: true,
}).extend({
version: z.string().min(1),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
tiles: z.array(TileTemplateSchema),
filters: z.array(DashboardFilterSchema).optional(),
});
export type DashboardTemplate = z.infer<typeof DashboardTemplateSchema>;
export const ConnectionSchema = z.object({
id: z.string(),