mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
68918e4711
commit
50ba92ac24
13 changed files with 1413 additions and 33 deletions
7
.changeset/nervous-zoos-remember.md
Normal file
7
.changeset/nervous-zoos-remember.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add custom filters to the services dashboard"
|
||||
62
packages/api/src/controllers/presetDashboardFilters.ts
Normal file
62
packages/api/src/controllers/presetDashboardFilters.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
55
packages/api/src/models/presetDashboardFilter.ts
Normal file
55
packages/api/src/models/presetDashboardFilter.ts
Normal 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,
|
||||
);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const DashboardFilterSelect = ({
|
|||
select: '',
|
||||
},
|
||||
keys: [filter.expression],
|
||||
disableRowLimit: true,
|
||||
limit: 10000,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
packages/app/src/hooks/usePresetDashboardFilters.tsx
Normal file
102
packages/app/src/hooks/usePresetDashboardFilters.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue