feat: Add filters to the Services dashboard (#1494)

Closes HDX-3015

# Summary

This PR adds custom filters to the services dashboard.

Notes:

- These filters are per-source, per-dashboard. Different sources have different schemas, so we must store them per-source to avoid invalid filters being available for some sources.
- These filters are stored in a new collection in MongoDB (PresetDashboardFilters) and accessed via a new set of CRUD APIs
- The UI is 99% re-used from the existing custom dashboard filters

## Demo

https://github.com/user-attachments/assets/82a4a55f-9b8b-46eb-be24-82254a86eed3
This commit is contained in:
Drew Davis 2025-12-17 13:39:05 -05:00 committed by GitHub
parent 68918e4711
commit 50ba92ac24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1413 additions and 33 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add custom filters to the services dashboard"

View file

@ -0,0 +1,62 @@
import {
PresetDashboard,
PresetDashboardFilter,
} from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';
import { ObjectId } from '@/models';
import PresetDashboardFilterModel from '@/models/presetDashboardFilter';
export async function getPresetDashboardFilters(
teamId: string | ObjectId,
source: string | ObjectId,
presetDashboard: PresetDashboard,
) {
return await PresetDashboardFilterModel.find({
team: new mongoose.Types.ObjectId(teamId),
source: new mongoose.Types.ObjectId(source),
presetDashboard,
});
}
export const createPresetDashboardFilter = async (
teamId: string | ObjectId,
presetDashboardFilter: PresetDashboardFilter,
) => {
const newPresetDashboardFilter = new PresetDashboardFilterModel({
...presetDashboardFilter,
team: new mongoose.Types.ObjectId(teamId),
});
return newPresetDashboardFilter.save();
};
export const updatePresetDashboardFilter = async (
teamId: string | ObjectId,
presetDashboardFilter: PresetDashboardFilter,
) => {
return await PresetDashboardFilterModel.findOneAndUpdate(
{
_id: new mongoose.Types.ObjectId(presetDashboardFilter.id),
team: new mongoose.Types.ObjectId(teamId),
},
{
...presetDashboardFilter,
_id: new mongoose.Types.ObjectId(presetDashboardFilter.id),
team: new mongoose.Types.ObjectId(teamId),
},
{ new: true },
);
};
export const deletePresetDashboardFilter = async (
teamId: string | ObjectId,
presetDashboard: PresetDashboard,
presetDashboardFilterId: string | ObjectId,
) => {
return await PresetDashboardFilterModel.findOneAndDelete({
_id: new mongoose.Types.ObjectId(presetDashboardFilterId),
team: new mongoose.Types.ObjectId(teamId),
presetDashboard,
});
};

View file

@ -0,0 +1,55 @@
import {
MetricsDataType,
PresetDashboard,
PresetDashboardFilter,
} from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
export interface IPresetDashboardFilter
extends Omit<PresetDashboardFilter, 'source'> {
_id: ObjectId;
team: ObjectId;
source: ObjectId;
}
const PresetDashboardFilterSchema = new Schema<IPresetDashboardFilter>(
{
name: {
type: String,
required: true,
},
team: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Team',
},
source: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Source',
},
sourceMetricType: {
type: String,
required: false,
enum: Object.values(MetricsDataType),
},
presetDashboard: {
type: String,
required: true,
enum: Object.values(PresetDashboard),
},
type: { type: String, required: true },
expression: { type: String, required: true },
},
{
timestamps: true,
toJSON: { getters: true },
},
);
export default mongoose.model<IPresetDashboardFilter>(
'PresetDashboardFilter',
PresetDashboardFilterSchema,
);

View file

@ -1,6 +1,15 @@
import { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
import {
AlertThresholdType,
MetricsDataType,
PresetDashboard,
SourceKind,
TSourceUnion,
} from '@hyperdx/common-utils/dist/types';
import { omit } from 'lodash';
import mongoose from 'mongoose';
import mongoose, { Types } from 'mongoose';
import PresetDashboardFilter from '@/models/presetDashboardFilter';
import { Source } from '@/models/source';
import {
getLoggedInAgent,
@ -359,4 +368,488 @@ describe('dashboard router', () => {
// Alert should have updated threshold
expect(updatedAlertRecord.threshold).toBe(updatedThreshold);
});
describe('preset dashboards', () => {
const MOCK_SOURCE: Omit<Extract<TSourceUnion, { kind: 'log' }>, 'id'> = {
kind: SourceKind.Log,
name: 'Test Source',
connection: new Types.ObjectId().toString(),
from: {
databaseName: 'test_db',
tableName: 'test_table',
},
timestampValueExpression: 'timestamp',
defaultTableSelectExpression: 'body',
};
const MOCK_PRESET_DASHBOARD_FILTER = {
name: 'Test Filter',
type: 'QUERY_EXPRESSION',
expression: 'service.name:test-service',
presetDashboard: PresetDashboard.Services,
};
describe('GET /preset/:presetDashboard/filters', () => {
it('returns preset dashboard filters for a given source', async () => {
const { agent, team } = await getLoggedInAgent(server);
// Create a test source
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
// Create a preset dashboard filter
const filter = await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team._id,
source: source._id,
});
const response = await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: source._id.toString() })
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0]).toMatchObject({
name: MOCK_PRESET_DASHBOARD_FILTER.name,
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: MOCK_PRESET_DASHBOARD_FILTER.expression,
presetDashboard: MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
source: source._id.toString(),
id: filter._id.toString(),
});
});
it('returns empty array when no filters exist for source', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const response = await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: source._id.toString() })
.expect(200);
expect(response.body).toEqual([]);
});
it('returns 400 when sourceId is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.expect(400);
});
it('returns 400 when sourceId is empty', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: '' })
.expect(400);
});
it('returns 400 for invalid preset dashboard type', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
await agent
.get('/dashboards/preset/invalid-dashboard/filters')
.query({ sourceId: source._id.toString() })
.expect(400);
});
it('does not return filters from other teams in GET', async () => {
const { agent: agent1, team: team1 } = await getLoggedInAgent(server);
const team2 = new mongoose.Types.ObjectId();
const source1 = await Source.create({
...MOCK_SOURCE,
team: team1._id,
});
const source2 = await Source.create({
...MOCK_SOURCE,
team: team2,
});
await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team1._id,
source: source1._id,
});
await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team2,
source: source2._id,
});
const response = await agent1
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: source1._id.toString() })
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].team).toEqual(team1._id.toString());
});
});
describe('POST /preset/:presetDashboard/filter', () => {
it('creates a new preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const filterInput = {
...MOCK_PRESET_DASHBOARD_FILTER,
id: new Types.ObjectId().toString(),
source: source._id.toString(),
};
const response = await agent
.post(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: filterInput })
.expect(200);
expect(response.body).toMatchObject({
name: MOCK_PRESET_DASHBOARD_FILTER.name,
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: MOCK_PRESET_DASHBOARD_FILTER.expression,
presetDashboard: MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
});
// Verify filter was created in database
const filters = await PresetDashboardFilter.find({ team: team._id });
expect(filters).toHaveLength(1);
expect(filters[0]._id.toString()).toBe(response.body.id);
expect(filters[0].source.toString()).toBe(source._id.toString());
expect(filters[0].team.toString()).toBe(team._id.toString());
expect(filters[0].name).toBe(MOCK_PRESET_DASHBOARD_FILTER.name);
expect(filters[0].type).toBe(MOCK_PRESET_DASHBOARD_FILTER.type);
expect(filters[0].expression).toBe(
MOCK_PRESET_DASHBOARD_FILTER.expression,
);
expect(filters[0].presetDashboard).toBe(
MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
);
});
it('creates filter with optional sourceMetricType', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const filterInput = {
...MOCK_PRESET_DASHBOARD_FILTER,
id: new Types.ObjectId().toString(),
source: source._id.toString(),
sourceMetricType: MetricsDataType.Gauge,
};
const response = await agent
.post(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: filterInput })
.expect(200);
expect(response.body.sourceMetricType).toBe(MetricsDataType.Gauge);
});
it('returns 400 when filter preset dashboard does not match params', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const filterInput = {
...MOCK_PRESET_DASHBOARD_FILTER,
id: new Types.ObjectId().toString(),
source: source._id.toString(),
presetDashboard: PresetDashboard.Services,
};
// Try to create with mismatched preset dashboard in URL
await agent
.post('/dashboards/preset/invalid-dashboard/filter')
.send({ filter: filterInput })
.expect(400);
});
it('returns 400 when filter is missing required fields', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const incompleteFilter = {
name: 'Test Filter',
source: source._id.toString(),
// Missing type, expression, presetDashboard
};
await agent
.post(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: incompleteFilter })
.expect(400);
});
it('returns 400 when filter body is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({})
.expect(400);
});
});
describe('PUT /preset/:presetDashboard/filter', () => {
it('updates an existing preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
// Create initial filter
const existingFilter = await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team._id,
source: source._id,
});
const updatedFilterInput = {
id: existingFilter._id.toString(),
name: 'Updated Filter Name',
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: 'service.name:updated-service',
presetDashboard: MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
source: source._id.toString(),
};
const response = await agent
.put(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: updatedFilterInput })
.expect(200);
expect(response.body).toMatchObject({
name: 'Updated Filter Name',
expression: 'service.name:updated-service',
});
// Verify filter was updated in database
const updatedFilter = await PresetDashboardFilter.findById(
existingFilter._id,
);
expect(updatedFilter?.name).toBe('Updated Filter Name');
expect(updatedFilter?.expression).toBe('service.name:updated-service');
});
it('returns an error when the filter does not exist', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const newFilterInput = {
id: new Types.ObjectId().toString(),
name: 'New Filter',
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: 'service.name:new-service',
presetDashboard: MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
source: source._id.toString(),
};
await agent
.put(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: newFilterInput })
.expect(404);
});
it('updates filter with sourceMetricType', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const existingFilter = await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team._id,
source: source._id,
});
const updatedFilterInput = {
id: existingFilter._id.toString(),
name: MOCK_PRESET_DASHBOARD_FILTER.name,
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: MOCK_PRESET_DASHBOARD_FILTER.expression,
presetDashboard: MOCK_PRESET_DASHBOARD_FILTER.presetDashboard,
source: source._id.toString(),
sourceMetricType: MetricsDataType.Histogram,
};
const response = await agent
.put(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: updatedFilterInput })
.expect(200);
expect(response.body.sourceMetricType).toBe(MetricsDataType.Histogram);
});
it('returns 400 when filter preset dashboard does not match params', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const filterInput = {
id: new Types.ObjectId().toString(),
name: 'Test Filter',
type: MOCK_PRESET_DASHBOARD_FILTER.type,
expression: 'test',
presetDashboard: PresetDashboard.Services,
source: source._id.toString(),
};
// Try to update with mismatched preset dashboard in URL
await agent
.put('/dashboards/preset/invalid-dashboard/filter')
.send({ filter: filterInput })
.expect(400);
});
it('returns 400 when filter is missing required fields', async () => {
const { agent } = await getLoggedInAgent(server);
const incompleteFilter = {
id: new Types.ObjectId().toString(),
name: 'Test Filter',
// Missing type, expression, presetDashboard, source
};
await agent
.put(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({ filter: incompleteFilter })
.expect(400);
});
});
describe('DELETE /preset/:presetDashboard/filter/:id', () => {
it('deletes a preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
// Create a filter to delete
const filter = await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team._id,
source: source._id,
});
const response = await agent
.delete(
`/dashboards/preset/${PresetDashboard.Services}/filter/${filter._id}`,
)
.expect(200);
expect(response.body).toMatchObject({
id: filter._id.toString(),
});
// Verify filter was deleted from database
const deletedFilter = await PresetDashboardFilter.findById(filter._id);
expect(deletedFilter).toBeNull();
});
it('returns 404 when filter does not exist', async () => {
const { agent } = await getLoggedInAgent(server);
const nonExistentId = new Types.ObjectId().toString();
await agent
.delete(
`/dashboards/preset/${PresetDashboard.Services}/filter/${nonExistentId}`,
)
.expect(404);
});
it('returns 400 when id is invalid', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.delete('/dashboards/preset/services/filter/invalid-id')
.expect(400);
});
it('returns 400 for invalid preset dashboard type', async () => {
const { agent } = await getLoggedInAgent(server);
const filterId = new Types.ObjectId().toString();
await agent
.delete(`/dashboards/preset/invalid-dashboard/filter/${filterId}`)
.expect(400);
});
it('does not delete filters from other teams', async () => {
const { agent: agent } = await getLoggedInAgent(server); // team 1
const team2Id = new mongoose.Types.ObjectId();
const source = await Source.create({
...MOCK_SOURCE,
team: team2Id,
});
const filter = await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team2Id,
source: source._id,
});
// Try to delete team2's filter as team1
await agent
.delete(
`/dashboards/preset/${PresetDashboard.Services}/filter/${filter._id}`,
)
.expect(404);
// Verify filter still exists for team2
const stillExistingFilter = await PresetDashboardFilter.findById(
filter._id,
);
expect(stillExistingFilter).toBeTruthy();
});
});
});
});

View file

@ -1,9 +1,10 @@
import {
DashboardSchema,
DashboardWithoutIdSchema,
PresetDashboard,
PresetDashboardFilterSchema,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { groupBy } from 'lodash';
import _ from 'lodash';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
@ -15,7 +16,14 @@ import {
getDashboards,
updateDashboard,
} from '@/controllers/dashboard';
import {
createPresetDashboardFilter,
deletePresetDashboardFilter,
getPresetDashboardFilters,
updatePresetDashboardFilter,
} from '@/controllers/presetDashboardFilters';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import logger from '@/utils/logger';
import { objectIdSchema } from '@/utils/zod';
// create routes that will get and update dashboards
@ -107,4 +115,133 @@ router.delete(
},
);
router.get(
'/preset/:presetDashboard/filters',
validateRequest({
params: z.object({
presetDashboard: z.nativeEnum(PresetDashboard),
}),
query: z.object({
sourceId: objectIdSchema,
}),
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { presetDashboard } = req.params;
const { sourceId } = req.query;
const filters = await getPresetDashboardFilters(
teamId,
sourceId,
presetDashboard,
);
return res.json(filters);
} catch (e) {
next(e);
}
},
);
router.put(
'/preset/:presetDashboard/filter',
validateRequest({
body: z.object({
filter: PresetDashboardFilterSchema,
}),
params: z.object({
presetDashboard: z.nativeEnum(PresetDashboard),
}),
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { filter } = req.body;
if (filter.presetDashboard !== req.params.presetDashboard) {
return res
.status(400)
.json({ error: 'Preset dashboard in body and params do not match' });
}
const updatedPresetDashboardFilter = await updatePresetDashboardFilter(
teamId,
filter,
);
if (!updatedPresetDashboardFilter) {
return res.status(404).send();
}
return res.json(updatedPresetDashboardFilter);
} catch (e) {
next(e);
}
},
);
router.post(
'/preset/:presetDashboard/filter',
validateRequest({
body: z.object({
filter: PresetDashboardFilterSchema,
}),
params: z.object({
presetDashboard: z.nativeEnum(PresetDashboard),
}),
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { filter } = req.body;
if (filter.presetDashboard !== req.params.presetDashboard) {
return res
.status(400)
.json({ error: 'Preset dashboard in body and params do not match' });
}
const newPresetDashboardFilter = await createPresetDashboardFilter(
teamId,
filter,
);
return res.json(newPresetDashboardFilter);
} catch (e) {
next(e);
}
},
);
router.delete(
'/preset/:presetDashboard/filter/:id',
validateRequest({
params: z.object({
presetDashboard: z.nativeEnum(PresetDashboard),
id: objectIdSchema,
}),
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { presetDashboard, id } = req.params;
const deleted = await deletePresetDashboardFilter(
teamId,
presetDashboard,
id,
);
if (!deleted) {
return res.status(404).send();
}
return res.json(deleted);
} catch (e) {
next(e);
}
},
);
export default router;

View file

@ -1113,16 +1113,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<IconRefresh size={18} />
</Button>
</Tooltip>
<Tooltip withArrow label="Edit Filters" fz="xs" color="gray">
<Button
variant="default"
px="xs"
mr={6}
onClick={() => setShowFiltersModal(true)}
>
<IconFilterEdit strokeWidth={1} />
</Button>
</Tooltip>
{!IS_LOCAL_MODE && (
<Tooltip withArrow label="Edit Filters" fz="xs" color="gray">
<Button
variant="default"
px="xs"
mr={6}
onClick={() => setShowFiltersModal(true)}
>
<IconFilterEdit strokeWidth={1} />
</Button>
</Tooltip>
)}
<Button
data-testid="search-submit-button"
variant="outline"

View file

@ -44,6 +44,8 @@ const DashboardFilterSelect = ({
select: '',
},
keys: [filter.expression],
disableRowLimit: true,
limit: 10000,
},
{
enabled:

View file

@ -5,9 +5,11 @@ import {
DashboardFilter,
MetricsDataType,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Button,
Center,
Group,
Input,
Modal,
@ -24,6 +26,7 @@ import {
IconFilter,
IconInfoCircle,
IconPencil,
IconRefresh,
IconStack,
IconTrash,
} from '@tabler/icons-react';
@ -77,6 +80,7 @@ const CustomInputWrapper = ({
interface DashboardFilterEditFormProps {
filter: DashboardFilter;
isNew: boolean;
source: TSource | undefined;
onSave: (definition: DashboardFilter) => void;
onClose: () => void;
onCancel: () => void;
@ -85,6 +89,7 @@ interface DashboardFilterEditFormProps {
const DashboardFilterEditForm = ({
filter,
isNew,
source: presetSource,
onSave,
onClose,
onCancel,
@ -150,6 +155,7 @@ const DashboardFilterEditForm = ({
sourceSchemaPreview={
<SourceSchemaPreview source={source} variant="text" />
}
disabled={!!presetSource}
/>
</CustomInputWrapper>
{sourceIsMetric && (
@ -196,7 +202,7 @@ const DashboardFilterEditForm = ({
/>
</CustomInputWrapper>
<Group justify="flex-end" my="xs">
<Group justify="space-between" my="xs">
<Button variant="default" onClick={onCancel}>
Cancel
</Button>
@ -294,14 +300,16 @@ const DashboardFiltersList = ({
</Paper>
))}
{isLoading && (
<div
className="spinner-border mx-auto"
style={{ width: 14, height: 14 }}
/>
<Center>
<IconRefresh className="spin-animate" />
</Center>
)}
</Stack>
<Group justify="center" my="sm">
<Group justify="space-between" my="sm">
<Button variant="default" onClick={onClose}>
Close
</Button>
<Button variant="filled" onClick={onAddNew}>
Add new filter
</Button>
@ -314,6 +322,7 @@ interface DashboardFiltersEditModalProps {
opened: boolean;
filters: DashboardFilter[];
isLoading?: boolean;
source?: TSource;
onClose: () => void;
onSaveFilter: (filter: DashboardFilter) => void;
onRemoveFilter: (id: string) => void;
@ -325,6 +334,7 @@ const DashboardFiltersModal = ({
opened,
filters,
isLoading,
source,
onClose,
onSaveFilter,
onRemoveFilter,
@ -350,7 +360,7 @@ const DashboardFiltersModal = ({
type: 'QUERY_EXPRESSION',
name: '',
expression: '',
source: '',
source: source?.id ?? '',
});
};
@ -378,6 +388,7 @@ const DashboardFiltersModal = ({
onCancel={() => setSelectedFilter(undefined)}
onClose={onClose}
isNew={selectedFilter.id === NEW_FILTER_ID}
source={source}
/>
);
} else {

View file

@ -7,7 +7,7 @@ import {
useQueryState,
useQueryStates,
} from 'nuqs';
import { UseControllerProps, useForm, useWatch } from 'react-hook-form';
import { UseControllerProps, useForm } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
@ -16,6 +16,7 @@ import {
CteChartConfig,
DisplayType,
Filter,
PresetDashboard,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
@ -32,6 +33,7 @@ import {
import {
IconChartLine,
IconFilter,
IconFilterEdit,
IconPlayerPlay,
IconRefresh,
IconTable,
@ -68,15 +70,23 @@ import { useSource, useSources } from '@/source';
import { Histogram } from '@/SVGIcons';
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
import usePresetDashboardFilters from './hooks/usePresetDashboardFilters';
import { IS_LOCAL_MODE } from './config';
import DashboardFilters from './DashboardFilters';
import DashboardFiltersModal from './DashboardFiltersModal';
import { HARD_LINES_LIMIT } from './HDXMultiSeriesTimeChart';
type AppliedConfig = {
type AppliedConfigParams = {
source?: string | null;
service?: string | null;
where?: string | null;
whereLanguage?: 'sql' | 'lucene' | null;
};
type AppliedConfig = AppliedConfigParams & {
additionalFilters?: Filter[];
};
const MAX_NUM_SERIES = HARD_LINES_LIMIT;
function getScopedFilters({
@ -90,7 +100,7 @@ function getScopedFilters({
includeIsSpanKindServer?: boolean;
includeNonEmptyEndpointFilter?: boolean;
}): Filter[] {
const filters: Filter[] = [];
const filters: Filter[] = [...(appliedConfig.additionalFilters || [])];
// Database spans are of kind Client. To be cleaned up in HDX-1219
if (includeIsSpanKindServer) {
filters.push({
@ -1374,7 +1384,7 @@ function ServicesDashboardPage() {
useQueryStates(appliedConfigMap);
// Only use the source from the URL params if it is a trace source
const appliedConfig = useMemo(() => {
const appliedConfigWithoutFilters = useMemo(() => {
if (!sources?.length) return appliedConfigParams;
const traceSources = sources?.filter(s => s.kind === SourceKind.Trace);
@ -1396,8 +1406,8 @@ function ServicesDashboardPage() {
defaultValues: {
where: '',
whereLanguage: 'sql' as 'sql' | 'lucene',
service: appliedConfig?.service || '',
source: appliedConfig?.source ?? '',
service: appliedConfigWithoutFilters?.service || '',
source: appliedConfigWithoutFilters?.source ?? '',
},
});
@ -1407,16 +1417,39 @@ function ServicesDashboardPage() {
id: watch('source'),
});
const [showFiltersModal, setShowFiltersModal] = useState(false);
const {
filters,
filterValues,
setFilterValue,
filterQueries: additionalFilters,
handleSaveFilter,
handleRemoveFilter,
isFetching: isFetchingFilters,
isMutationPending: isFiltersMutationPending,
} = usePresetDashboardFilters({
presetDashboard: PresetDashboard.Services,
sourceId: sourceId || '',
});
const appliedConfig = useMemo(
() => ({
...appliedConfigWithoutFilters,
additionalFilters,
}),
[appliedConfigWithoutFilters, additionalFilters],
);
// Update the `source` query parameter if the appliedConfig source changes
useEffect(() => {
if (
appliedConfig.source &&
appliedConfig.source !== appliedConfigParams.source
appliedConfigWithoutFilters.source &&
appliedConfigWithoutFilters.source !== appliedConfigParams.source
) {
setAppliedConfigParams({ source: appliedConfig.source });
setAppliedConfigParams({ source: appliedConfigWithoutFilters.source });
}
}, [
appliedConfig.source,
appliedConfigWithoutFilters.source,
appliedConfigParams.source,
setAppliedConfigParams,
]);
@ -1552,6 +1585,17 @@ function ServicesDashboardPage() {
setInputValue={setDisplayedTimeInputValue}
onSearch={onSearch}
/>
{!IS_LOCAL_MODE && (
<Tooltip withArrow label="Edit Filters" fz="xs" color="gray">
<Button
variant="default"
px="xs"
onClick={() => setShowFiltersModal(true)}
>
<IconFilterEdit strokeWidth={1} />
</Button>
</Tooltip>
)}
<Tooltip withArrow label="Refresh dashboard" fz="xs" color="gray">
<Button
onClick={refresh}
@ -1572,6 +1616,12 @@ function ServicesDashboardPage() {
</Group>
</Group>
</form>
<DashboardFilters
filters={filters}
filterValues={filterValues}
onSetFilterValue={setFilterValue}
dateRange={searchedTimeRange}
/>
{source?.kind !== 'trace' ? (
<Group align="center" justify="center" h="300px">
<Text c="gray">Please select a trace source</Text>
@ -1609,6 +1659,15 @@ function ServicesDashboardPage() {
</Tabs.Panel>
</Tabs>
)}
<DashboardFiltersModal
opened={showFiltersModal}
onClose={() => setShowFiltersModal(false)}
filters={filters}
onSaveFilter={handleSaveFilter}
onRemoveFilter={handleRemoveFilter}
source={source}
isLoading={isFetchingFilters || isFiltersMutationPending}
/>
</Box>
);
}

View file

@ -1,10 +1,13 @@
import React from 'react';
import Router from 'next/router';
import type { HTTPError, Options, ResponsePromise } from 'ky';
import ky from 'ky-universal';
import type { Alert } from '@hyperdx/common-utils/dist/types';
import type {
Alert,
PresetDashboard,
PresetDashboardFilter,
} from '@hyperdx/common-utils/dist/types';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { IS_LOCAL_MODE } from './config';
import type { AlertsPageItem } from './types';
@ -169,6 +172,52 @@ const api = {
}).json(),
});
},
usePresetDashboardFilters(
presetDashboard: PresetDashboard,
sourceId: string,
) {
return useQuery({
queryKey: [`dashboards`, `preset`, presetDashboard, `filters`, sourceId],
queryFn: () =>
hdxServer(`dashboards/preset/${presetDashboard}/filters/`, {
method: 'GET',
searchParams: { sourceId },
}).json() as Promise<PresetDashboardFilter[]>,
enabled: !!sourceId,
});
},
useCreatePresetDashboardFilter() {
return useMutation({
mutationFn: async (filter: PresetDashboardFilter) =>
hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, {
method: 'POST',
json: { filter },
}).json(),
});
},
useUpdatePresetDashboardFilter() {
return useMutation({
mutationFn: async (filter: PresetDashboardFilter) =>
hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, {
method: 'PUT',
json: { filter },
}).json(),
});
},
useDeletePresetDashboardFilter() {
return useMutation({
mutationFn: async ({
id,
presetDashboard,
}: {
id: string;
presetDashboard: PresetDashboard;
}) =>
hdxServer(`dashboards/preset/${presetDashboard}/filter/${id}`, {
method: 'DELETE',
}).json(),
});
},
useAlerts() {
return useQuery({
queryKey: [`alerts`],

View file

@ -0,0 +1,391 @@
import {
DashboardFilter,
Filter,
PresetDashboard,
PresetDashboardFilter,
} from '@hyperdx/common-utils/dist/types';
import { act, renderHook } from '@testing-library/react';
import api from '@/api';
import { FilterState } from '@/searchFilters';
import useDashboardFilters from '../useDashboardFilters';
import usePresetDashboardFilters from '../usePresetDashboardFilters';
// Mock the api module
jest.mock('@/api', () => ({
__esModule: true,
default: {
usePresetDashboardFilters: jest.fn(),
useCreatePresetDashboardFilter: jest.fn(),
useUpdatePresetDashboardFilter: jest.fn(),
useDeletePresetDashboardFilter: jest.fn(),
},
}));
// Mock the useDashboardFilters hook
jest.mock('../useDashboardFilters', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('usePresetDashboardFilters', () => {
const mockSourceId = 'test-service-id';
const mockPresetDashboard = PresetDashboard.Services;
const mockFilters: PresetDashboardFilter[] = [
{
id: 'filter-1',
type: 'QUERY_EXPRESSION',
name: 'Environment',
expression: 'environment',
source: mockSourceId,
presetDashboard: PresetDashboard.Services,
},
{
id: 'filter-2',
type: 'QUERY_EXPRESSION',
name: 'Status Code',
expression: 'status_code',
source: mockSourceId,
presetDashboard: PresetDashboard.Services,
},
];
const mockFilterValues: FilterState = {
environment: {
included: new Set(['production']),
excluded: new Set(),
},
};
const mockFilterQueries: Filter[] = [
{
type: 'sql',
condition: "environment = 'production'",
},
];
let mockRefetch: jest.Mock;
let mockCreateMutate: jest.Mock;
let mockUpdateMutate: jest.Mock;
let mockDeleteMutate: jest.Mock;
let mockSetFilterValue: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
// Setup mocks
mockRefetch = jest.fn();
mockCreateMutate = jest.fn();
mockUpdateMutate = jest.fn();
mockDeleteMutate = jest.fn();
mockSetFilterValue = jest.fn();
// Mock usePresetDashboardFilters query
jest.mocked(api.usePresetDashboardFilters).mockReturnValue({
data: mockFilters,
refetch: mockRefetch,
} as any);
// Mock create mutation
jest.mocked(api.useCreatePresetDashboardFilter).mockReturnValue({
mutate: mockCreateMutate,
} as any);
// Mock update mutation
jest.mocked(api.useUpdatePresetDashboardFilter).mockReturnValue({
mutate: mockUpdateMutate,
} as any);
// Mock delete mutation
jest.mocked(api.useDeletePresetDashboardFilter).mockReturnValue({
mutate: mockDeleteMutate,
} as any);
// Mock useDashboardFilters
jest.mocked(useDashboardFilters).mockReturnValue({
filterValues: mockFilterValues,
setFilterValue: mockSetFilterValue,
filterQueries: mockFilterQueries,
});
});
it('should initialize with filters from API', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
expect(result.current.filters).toEqual(mockFilters);
expect(api.usePresetDashboardFilters).toHaveBeenCalledWith(
PresetDashboard.Services,
mockSourceId,
);
});
it('should return empty array when no data is available', () => {
jest.mocked(api.usePresetDashboardFilters).mockReturnValue({
data: undefined,
refetch: mockRefetch,
} as any);
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
expect(result.current.filters).toEqual([]);
});
it('should pass filters to useDashboardFilters', () => {
renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
expect(useDashboardFilters).toHaveBeenCalledWith(mockFilters);
});
it('should pass empty array to useDashboardFilters when no data', () => {
jest.mocked(api.usePresetDashboardFilters).mockReturnValue({
data: undefined,
refetch: mockRefetch,
} as any);
renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
expect(useDashboardFilters).toHaveBeenCalledWith([]);
});
it('should return filter values and queries from useDashboardFilters', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
expect(result.current.filterValues).toEqual(mockFilterValues);
expect(result.current.filterQueries).toEqual(mockFilterQueries);
expect(result.current.setFilterValue).toBe(mockSetFilterValue);
});
describe('handleSaveFilter', () => {
it('should create a new filter when it does not exist', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const newFilter: DashboardFilter = {
id: 'new-filter',
type: 'QUERY_EXPRESSION',
name: 'Region',
expression: 'region',
source: mockSourceId,
};
act(() => {
result.current.handleSaveFilter(newFilter);
});
expect(mockCreateMutate).toHaveBeenCalledWith(
{
...newFilter,
presetDashboard: mockPresetDashboard,
},
{ onSuccess: expect.any(Function), onError: expect.any(Function) },
);
expect(mockUpdateMutate).not.toHaveBeenCalled();
});
it('should update an existing filter when it exists', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const updatedFilter: DashboardFilter = {
id: 'filter-1',
type: 'QUERY_EXPRESSION',
name: 'Environment (Updated)',
expression: 'environment',
source: mockSourceId,
};
act(() => {
result.current.handleSaveFilter(updatedFilter);
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
{
...updatedFilter,
presetDashboard: mockPresetDashboard,
},
{ onSuccess: expect.any(Function), onError: expect.any(Function) },
);
expect(mockCreateMutate).not.toHaveBeenCalled();
});
it('should call refetch on successful create', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const newFilter: DashboardFilter = {
id: 'new-filter',
type: 'QUERY_EXPRESSION',
name: 'Region',
expression: 'region',
source: mockSourceId,
};
act(() => {
result.current.handleSaveFilter(newFilter);
});
// Get the onSuccess callback that was passed to mutate
const onSuccess = mockCreateMutate.mock.calls[0][1].onSuccess;
// Call it to simulate successful mutation
act(() => {
onSuccess();
});
expect(mockRefetch).toHaveBeenCalled();
});
it('should call refetch on successful update', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const updatedFilter: DashboardFilter = {
id: 'filter-1',
type: 'QUERY_EXPRESSION',
name: 'Environment (Updated)',
expression: 'environment',
source: mockSourceId,
};
act(() => {
result.current.handleSaveFilter(updatedFilter);
});
// Get the onSuccess callback that was passed to mutate
const onSuccess = mockUpdateMutate.mock.calls[0][1].onSuccess;
// Call it to simulate successful mutation
act(() => {
onSuccess();
});
expect(mockRefetch).toHaveBeenCalled();
});
});
describe('handleRemoveFilter', () => {
it('should call delete mutation with correct parameters', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const filterIdToRemove = 'filter-1';
act(() => {
result.current.handleRemoveFilter(filterIdToRemove);
});
expect(mockDeleteMutate).toHaveBeenCalledWith(
{
id: filterIdToRemove,
presetDashboard: mockPresetDashboard,
},
{ onSuccess: expect.any(Function), onError: expect.any(Function) },
);
});
it('should call refetch on successful delete', () => {
const { result } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const filterIdToRemove = 'filter-1';
act(() => {
result.current.handleRemoveFilter(filterIdToRemove);
});
// Get the onSuccess callback that was passed to mutate
const onSuccess = mockDeleteMutate.mock.calls[0][1].onSuccess;
// Call it to simulate successful mutation
act(() => {
onSuccess();
});
expect(mockRefetch).toHaveBeenCalled();
});
});
describe('hook dependencies', () => {
it('should use correct preset dashboard value', () => {
renderHook(() =>
usePresetDashboardFilters({
presetDashboard: PresetDashboard.Services,
sourceId: mockSourceId,
}),
);
expect(api.usePresetDashboardFilters).toHaveBeenCalledWith(
PresetDashboard.Services,
mockSourceId,
);
});
it('should maintain stable callbacks on re-render', () => {
const { result, rerender } = renderHook(() =>
usePresetDashboardFilters({
presetDashboard: mockPresetDashboard,
sourceId: mockSourceId,
}),
);
const firstHandleSaveFilter = result.current.handleSaveFilter;
const firstHandleRemoveFilter = result.current.handleRemoveFilter;
rerender();
expect(result.current.handleSaveFilter).toBe(firstHandleSaveFilter);
expect(result.current.handleRemoveFilter).toBe(firstHandleRemoveFilter);
});
});
});

View file

@ -0,0 +1,102 @@
import { useCallback, useMemo } from 'react';
import {
DashboardFilter,
PresetDashboard,
} from '@hyperdx/common-utils/dist/types';
import { notifications } from '@mantine/notifications';
import api from '@/api';
import useDashboardFilters from './useDashboardFilters';
export default function usePresetDashboardFilters({
presetDashboard,
sourceId,
}: {
presetDashboard: PresetDashboard;
sourceId: string;
}) {
const createDashboardFilter = api.useCreatePresetDashboardFilter();
const updateDashboardFilter = api.useUpdatePresetDashboardFilter();
const deleteDashboardFilter = api.useDeletePresetDashboardFilter();
const { data, refetch, isFetching } = api.usePresetDashboardFilters(
presetDashboard,
sourceId || '',
);
const { filterValues, setFilterValue, filterQueries } = useDashboardFilters(
data ?? [],
);
const onSuccess = useCallback(() => {
refetch();
notifications.show({
message: 'Filters updated',
color: 'green',
});
}, [refetch]);
const onError = useCallback(() => {
notifications.show({
message: 'Error updating filters',
color: 'red',
});
}, []);
const handleSaveFilter = useCallback(
(dashboardFilter: DashboardFilter) => {
const presetDashboardFilter = {
...dashboardFilter,
presetDashboard,
};
if (data?.find(f => f.id === dashboardFilter.id)) {
updateDashboardFilter.mutate(presetDashboardFilter, {
onSuccess,
onError,
});
} else {
createDashboardFilter.mutate(presetDashboardFilter, {
onSuccess,
onError,
});
}
},
[
data,
updateDashboardFilter,
createDashboardFilter,
presetDashboard,
onSuccess,
onError,
],
);
const handleRemoveFilter = useCallback(
(id: string) => {
deleteDashboardFilter.mutate(
{
id,
presetDashboard,
},
{ onSuccess, onError },
);
},
[deleteDashboardFilter, presetDashboard, onSuccess, onError],
);
return {
filters: data ?? [],
filterValues,
setFilterValue,
filterQueries,
handleSaveFilter,
handleRemoveFilter,
isFetching: isFetching,
isMutationPending:
createDashboardFilter.isPending ||
updateDashboardFilter.isPending ||
deleteDashboardFilter.isPending,
};
}

View file

@ -506,6 +506,16 @@ export const DashboardFilterSchema = z.object({
export type DashboardFilter = z.infer<typeof DashboardFilterSchema>;
export enum PresetDashboard {
Services = 'services',
}
export const PresetDashboardFilterSchema = DashboardFilterSchema.extend({
presetDashboard: z.nativeEnum(PresetDashboard),
});
export type PresetDashboardFilter = z.infer<typeof PresetDashboardFilterSchema>;
export const DashboardSchema = z.object({
id: z.string(),
name: z.string().min(1),