diff --git a/.changeset/angry-wasps-boil.md b/.changeset/angry-wasps-boil.md new file mode 100644 index 00000000..d57c96b7 --- /dev/null +++ b/.changeset/angry-wasps-boil.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +support saved query/filter values in external api diff --git a/packages/api/openapi.json b/packages/api/openapi.json index a0be5a0a..ffd0b5a9 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -508,6 +508,26 @@ ], "description": "Query language for the where clause." }, + "SavedFilterValue": { + "type": "object", + "required": [ + "condition" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "sql" + ], + "default": "sql" + }, + "condition": { + "type": "string", + "description": "SQL filter condition. For example use expressions in the form \"column IN ('value')\".", + "example": "ServiceName IN ('hdx-oss-dev-api')" + } + } + }, "MetricDataType": { "type": "string", "enum": [ @@ -1525,6 +1545,26 @@ "items": { "$ref": "#/components/schemas/Filter" } + }, + "savedQuery": { + "type": "string", + "nullable": true, + "description": "Optional default dashboard query restored when loading the dashboard.", + "example": "service.name = 'api'" + }, + "savedQueryLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "nullable": true, + "description": "Query language used by savedQuery.", + "default": "lucene", + "example": "sql" + }, + "savedFilterValues": { + "type": "array", + "description": "Optional default dashboard filter values restored when loading the dashboard.", + "items": { + "$ref": "#/components/schemas/SavedFilterValue" + } } } }, @@ -1563,6 +1603,26 @@ "items": { "$ref": "#/components/schemas/FilterInput" } + }, + "savedQuery": { + "type": "string", + "nullable": true, + "description": "Optional default dashboard query to persist on the dashboard.", + "example": "service.name = 'api'" + }, + "savedQueryLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "nullable": true, + "description": "Query language used by savedQuery.", + "default": "lucene", + "example": "sql" + }, + "savedFilterValues": { + "type": "array", + "description": "Optional default dashboard filter values to persist on the dashboard.", + "items": { + "$ref": "#/components/schemas/SavedFilterValue" + } } } }, @@ -1603,6 +1663,26 @@ "items": { "$ref": "#/components/schemas/Filter" } + }, + "savedQuery": { + "type": "string", + "nullable": true, + "description": "Optional default dashboard query to persist on the dashboard.", + "example": "service.name = 'api'" + }, + "savedQueryLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "nullable": true, + "description": "Query language used by savedQuery.", + "default": "lucene", + "example": "sql" + }, + "savedFilterValues": { + "type": "array", + "description": "Optional default dashboard filter values to persist on the dashboard.", + "items": { + "$ref": "#/components/schemas/SavedFilterValue" + } } } }, diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index 0cba6cec..18109b22 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -40,6 +40,9 @@ describe('External API v2 Dashboards - old format', () => { name: 'Test External Dashboard', tiles: [makeExternalChart({ sourceId }), makeExternalChart({ sourceId })], tags: TEST_TAGS, + savedQuery: null, + savedQueryLanguage: null, + savedFilterValues: [], ...overrides, }); @@ -57,6 +60,9 @@ describe('External API v2 Dashboards - old format', () => { ], tags: TEST_TAGS, filters: [], + savedQuery: null, + savedQueryLanguage: null, + savedFilterValues: [], ...overrides, }); @@ -280,6 +286,9 @@ describe('External API v2 Dashboards - old format', () => { ], tags: ['format-test'], filters: [], + savedQuery: null, + savedQueryLanguage: null, + savedFilterValues: [], }, }); @@ -362,6 +371,80 @@ describe('External API v2 Dashboards - old format', () => { expect(dashboards[0].tiles).toHaveLength(2); }); + it('should create a dashboard with saved query defaults', async () => { + const mockDashboard = createMockDashboard(traceSource._id.toString(), { + savedQuery: "service.name = 'api'", + savedQueryLanguage: 'sql', + savedFilterValues: [ + { + type: 'sql', + condition: "ServiceName IN ('hdx-oss-dev-api')", + }, + ], + }); + + const response = await authRequest('post', BASE_URL) + .send(mockDashboard) + .expect(200); + + expect(response.body.data.savedQuery).toBe("service.name = 'api'"); + expect(response.body.data.savedQueryLanguage).toBe('sql'); + expect(response.body.data.savedFilterValues).toEqual([ + { + type: 'sql', + condition: "ServiceName IN ('hdx-oss-dev-api')", + }, + ]); + + const dashboardInDb = await Dashboard.findById( + response.body.data.id, + ).lean(); + expect(dashboardInDb?.savedQuery).toBe("service.name = 'api'"); + expect(dashboardInDb?.savedQueryLanguage).toBe('sql'); + expect(dashboardInDb?.savedFilterValues).toEqual([ + { + type: 'sql', + condition: "ServiceName IN ('hdx-oss-dev-api')", + }, + ]); + }); + + it('should default savedQueryLanguage to lucene when savedQuery is provided without a language', async () => { + const mockDashboard = omit( + createMockDashboard(traceSource._id.toString(), { + savedQuery: "service.name = 'api'", + }), + 'savedQueryLanguage', + ); + + const response = await authRequest('post', BASE_URL) + .send(mockDashboard) + .expect(200); + + expect(response.body.data.savedQuery).toBe("service.name = 'api'"); + expect(response.body.data.savedQueryLanguage).toBe('lucene'); + + const dashboardInDb = await Dashboard.findById( + response.body.data.id, + ).lean(); + expect(dashboardInDb?.savedQueryLanguage).toBe('lucene'); + }); + + it('should return 400 when savedQueryLanguage is null and savedQuery is provided', async () => { + const mockDashboard = createMockDashboard(traceSource._id.toString(), { + savedQuery: "service.name = 'api'", + savedQueryLanguage: null, + }); + + const response = await authRequest('post', BASE_URL) + .send(mockDashboard) + .expect(400); + + expect(response.body.message).toContain( + 'savedQueryLanguage cannot be null when savedQuery is provided', + ); + }); + it('can create all chart types', async () => { const dashboardWithAllCharts = { name: 'Test Dashboard with All Chart Types', @@ -881,6 +964,56 @@ describe('External API v2 Dashboards - old format', () => { expect(updatedDashboardInDb?.tiles).toHaveLength(2); }); + it('should update and clear saved query defaults', async () => { + const dashboard = await createTestDashboard({ + savedQuery: 'service:api', + savedQueryLanguage: 'lucene', + savedFilterValues: [ + { + type: 'lucene', + condition: 'env:prod', + }, + ], + }); + const updatedDashboard = createMockDashboardWithIds( + traceSource._id.toString(), + ); + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send(updatedDashboard) + .expect(200); + + expect(response.body.data.savedQuery).toBeNull(); + expect(response.body.data.savedQueryLanguage).toBeNull(); + expect(response.body.data.savedFilterValues).toEqual([]); + + const updatedDashboardInDb = await Dashboard.findById( + dashboard._id, + ).lean(); + expect(updatedDashboardInDb?.savedQuery).toBeNull(); + expect(updatedDashboardInDb?.savedQueryLanguage).toBeNull(); + expect(updatedDashboardInDb?.savedFilterValues).toEqual([]); + }); + + it('should return 400 when savedQueryLanguage is null and savedQuery is provided on update', async () => { + const dashboard = await createTestDashboard(); + const updatedDashboard = createMockDashboardWithIds( + traceSource._id.toString(), + { + savedQuery: "service.name = 'api'", + savedQueryLanguage: null, + }, + ); + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send(updatedDashboard) + .expect(400); + + expect(response.body.message).toContain( + 'savedQueryLanguage cannot be null when savedQuery is provided', + ); + }); + it('should update dashboard filters when provided', async () => { const dashboard = await createTestDashboard(); const filterId1 = new ObjectId().toString(); @@ -1814,6 +1947,9 @@ describe('External API v2 Dashboards - new format', () => { ], tags: ['format-test'], filters: [], + savedQuery: null, + savedQueryLanguage: null, + savedFilterValues: [], }, }); @@ -1924,6 +2060,21 @@ describe('External API v2 Dashboards - new format', () => { expect(dashboards[0].tiles).toHaveLength(2); }); + it('should return 400 when savedQueryLanguage is null and savedQuery is provided', async () => { + const mockDashboard = createMockDashboard(traceSource._id.toString(), { + savedQuery: "service.name = 'api'", + savedQueryLanguage: null, + }); + + const response = await authRequest('post', BASE_URL) + .send(mockDashboard) + .expect(400); + + expect(response.body.message).toContain( + 'savedQueryLanguage cannot be null when savedQuery is provided', + ); + }); + it('can create all chart types', async () => { const dashboardWithAllCharts = { name: 'Test Dashboard with All Chart Types', @@ -2306,6 +2457,25 @@ describe('External API v2 Dashboards - new format', () => { expect(updatedDashboardInDb?.tiles).toHaveLength(2); }); + it('should return 400 when savedQueryLanguage is null and savedQuery is provided on update', async () => { + const dashboard = await createTestDashboard(); + const updatedDashboard = createMockDashboardWithIds( + traceSource._id.toString(), + { + savedQuery: "service.name = 'api'", + savedQueryLanguage: null, + }, + ); + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send(updatedDashboard) + .expect(400); + + expect(response.body.message).toContain( + 'savedQueryLanguage cannot be null when savedQuery is provided', + ); + }); + it('should update dashboard filters when provided', async () => { const dashboard = await createTestDashboard(); const filterId1 = new ObjectId().toString(); diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index ef57896b..a1ae421a 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -1,3 +1,4 @@ +import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import { uniq } from 'lodash'; import { ObjectId } from 'mongodb'; @@ -17,6 +18,7 @@ import { externalDashboardFilterSchema, externalDashboardFilterSchemaWithId, ExternalDashboardFilterWithId, + externalDashboardSavedFilterValueSchema, externalDashboardTileListSchema, ExternalDashboardTileWithId, objectIdSchema, @@ -67,6 +69,62 @@ async function getMissingSources( return [...sourceIds].filter(sourceId => !existingSourceIds.has(sourceId)); } +type SavedQueryLanguage = z.infer; + +function resolveSavedQueryLanguage(params: { + savedQuery: string | null | undefined; + savedQueryLanguage: SavedQueryLanguage | null | undefined; +}): SavedQueryLanguage | null | undefined { + const { savedQuery, savedQueryLanguage } = params; + if (savedQueryLanguage !== undefined) return savedQueryLanguage; + if (savedQuery === null) return null; + if (savedQuery) return 'lucene'; + + return undefined; +} + +const dashboardBodyBaseShape = { + name: z.string().max(1024), + tiles: externalDashboardTileListSchema, + tags: tagsSchema, + savedQuery: z.string().nullable().optional(), + savedQueryLanguage: whereLanguageSchema.nullable().optional(), + savedFilterValues: z + .array(externalDashboardSavedFilterValueSchema) + .optional(), +}; + +function buildDashboardBodySchema(filterSchema: z.ZodTypeAny): z.ZodEffects< + z.ZodObject< + typeof dashboardBodyBaseShape & { + filters: z.ZodOptional>; + } + > +> { + return z + .object({ + ...dashboardBodyBaseShape, + filters: z.array(filterSchema).optional(), + }) + .superRefine((data, ctx) => { + if (data.savedQuery != null && data.savedQueryLanguage === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'savedQueryLanguage cannot be null when savedQuery is provided', + path: ['savedQueryLanguage'], + }); + } + }); +} + +const createDashboardBodySchema = buildDashboardBodySchema( + externalDashboardFilterSchema, +); +const updateDashboardBodySchema = buildDashboardBodySchema( + externalDashboardFilterSchemaWithId, +); + /** * @openapi * components: @@ -83,6 +141,18 @@ async function getMissingSources( * type: string * enum: [sql, lucene] * description: Query language for the where clause. + * SavedFilterValue: + * type: object + * required: [condition] + * properties: + * type: + * type: string + * enum: [sql] + * default: sql + * condition: + * type: string + * description: SQL filter condition. For example use expressions in the form "column IN ('value')". + * example: "ServiceName IN ('hdx-oss-dev-api')" * MetricDataType: * type: string * enum: [sum, gauge, histogram, summary, exponential histogram] @@ -838,6 +908,22 @@ async function getMissingSources( * description: Dashboard filter keys added to the dashboard and applied to all tiles * items: * $ref: '#/components/schemas/Filter' + * savedQuery: + * type: string + * nullable: true + * description: Optional default dashboard query restored when loading the dashboard. + * example: "service.name = 'api'" + * savedQueryLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * nullable: true + * description: Query language used by savedQuery. + * default: "lucene" + * example: "sql" + * savedFilterValues: + * type: array + * description: Optional default dashboard filter values restored when loading the dashboard. + * items: + * $ref: '#/components/schemas/SavedFilterValue' * * CreateDashboardRequest: * type: object @@ -865,6 +951,22 @@ async function getMissingSources( * description: Dashboard filter keys to add to the dashboard and apply across all tiles * items: * $ref: '#/components/schemas/FilterInput' + * savedQuery: + * type: string + * nullable: true + * description: Optional default dashboard query to persist on the dashboard. + * example: "service.name = 'api'" + * savedQueryLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * nullable: true + * description: Query language used by savedQuery. + * default: "lucene" + * example: "sql" + * savedFilterValues: + * type: array + * description: Optional default dashboard filter values to persist on the dashboard. + * items: + * $ref: '#/components/schemas/SavedFilterValue' * * UpdateDashboardRequest: * type: object @@ -893,6 +995,22 @@ async function getMissingSources( * description: Dashboard filter keys on the dashboard, applied across all tiles * items: * $ref: '#/components/schemas/Filter' + * savedQuery: + * type: string + * nullable: true + * description: Optional default dashboard query to persist on the dashboard. + * example: "service.name = 'api'" + * savedQueryLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * nullable: true + * description: Query language used by savedQuery. + * default: "lucene" + * example: "sql" + * savedFilterValues: + * type: array + * description: Optional default dashboard filter values to persist on the dashboard. + * items: + * $ref: '#/components/schemas/SavedFilterValue' * * DashboardResponse: * allOf: @@ -994,7 +1112,16 @@ router.get('/', async (req, res, next) => { const dashboards = await Dashboard.find( { team: teamId }, - { _id: 1, name: 1, tiles: 1, tags: 1, filters: 1 }, + { + _id: 1, + name: 1, + tiles: 1, + tags: 1, + filters: 1, + savedQuery: 1, + savedQueryLanguage: 1, + savedFilterValues: 1, + }, ).sort({ name: -1 }); res.json({ @@ -1102,7 +1229,16 @@ router.get( const dashboard = await Dashboard.findOne( { team: teamId, _id: req.params.id }, - { _id: 1, name: 1, tiles: 1, tags: 1, filters: 1 }, + { + _id: 1, + name: 1, + tiles: 1, + tags: 1, + filters: 1, + savedQuery: 1, + savedQueryLanguage: 1, + savedFilterValues: 1, + }, ); if (dashboard == null) { @@ -1252,12 +1388,7 @@ router.get( router.post( '/', validateRequest({ - body: z.object({ - name: z.string().max(1024), - tiles: externalDashboardTileListSchema, - tags: tagsSchema, - filters: z.array(externalDashboardFilterSchema).optional(), - }), + body: createDashboardBodySchema, }), async (req, res, next) => { try { @@ -1266,7 +1397,15 @@ router.post( return res.sendStatus(403); } - const { name, tiles, tags, filters } = req.body; + const { + name, + tiles, + tags, + filters, + savedQuery, + savedQueryLanguage, + savedFilterValues, + } = req.body; const missingSources = await getMissingSources(teamId, tiles, filters); if (missingSources.length > 0) { @@ -1299,11 +1438,19 @@ router.post( }), ); + const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({ + savedQuery, + savedQueryLanguage, + }); + const newDashboard = await new Dashboard({ name, tiles: internalTiles, tags: tags && uniq(tags), filters: filtersWithIds, + savedQuery, + savedQueryLanguage: normalizedSavedQueryLanguage, + savedFilterValues, team: teamId, }).save(); @@ -1460,12 +1607,7 @@ router.put( params: z.object({ id: objectIdSchema, }), - body: z.object({ - name: z.string().max(1024), - tiles: externalDashboardTileListSchema, - tags: tagsSchema, - filters: z.array(externalDashboardFilterSchemaWithId).optional(), - }), + body: updateDashboardBodySchema, }), async (req, res, next) => { try { @@ -1478,7 +1620,15 @@ router.put( return res.sendStatus(400); } - const { name, tiles, tags, filters } = req.body ?? {}; + const { + name, + tiles, + tags, + filters, + savedQuery, + savedQueryLanguage, + savedFilterValues, + } = req.body ?? {}; const missingSources = await getMissingSources(teamId, tiles, filters); if (missingSources.length > 0) { @@ -1528,6 +1678,19 @@ router.put( }, ); } + if (savedQuery !== undefined) { + setPayload.savedQuery = savedQuery; + } + const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({ + savedQuery, + savedQueryLanguage, + }); + if (normalizedSavedQueryLanguage !== undefined) { + setPayload.savedQueryLanguage = normalizedSavedQueryLanguage; + } + if (savedFilterValues !== undefined) { + setPayload.savedFilterValues = savedFilterValues; + } const updatedDashboard = await Dashboard.findOneAndUpdate( { _id: dashboardId, team: teamId }, diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index b80d6208..25b2ceba 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -47,6 +47,9 @@ export type ExternalDashboard = { tiles: ExternalDashboardTileWithId[]; tags?: string[]; filters?: ExternalDashboardFilterWithId[]; + savedQuery?: string | null; + savedQueryLanguage?: string | null; + savedFilterValues?: DashboardDocument['savedFilterValues']; }; // -------------------------------------------------------------------------------- @@ -196,6 +199,9 @@ export function convertToExternalDashboard( tiles: dashboard.tiles.map(convertTileToExternalChart), tags: dashboard.tags || [], filters: dashboard.filters?.map(translateFilterToExternalFilter) || [], + savedQuery: dashboard.savedQuery ?? null, + savedQueryLanguage: dashboard.savedQueryLanguage ?? null, + savedFilterValues: dashboard.savedFilterValues ?? [], }; } diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 69a0e3bf..eee9a58a 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -143,6 +143,15 @@ export type ExternalDashboardFilter = z.infer< typeof externalDashboardFilterSchema >; +export const externalDashboardSavedFilterValueSchema = z.object({ + type: z.literal('sql').optional().default('sql'), + condition: z.string().max(10000), +}); + +export type ExternalDashboardSavedFilterValue = z.infer< + typeof externalDashboardSavedFilterValueSchema +>; + // ================================ // Dashboards (new format) // ================================ diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 82d8e2b4..8a6f1b4c 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -913,7 +913,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { : null; const currentFilterValues = rawFilterQueries?.length ? rawFilterQueries - : null; + : []; setDashboard( produce(dashboard, draft => { @@ -946,7 +946,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { produce(dashboard, draft => { draft.savedQuery = null; draft.savedQueryLanguage = null; - draft.savedFilterValues = null; + draft.savedFilterValues = []; }), () => { notifications.show({ diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 539d1eef..af818a53 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -32,7 +32,7 @@ export type Dashboard = { filters?: DashboardFilter[]; savedQuery?: string | null; savedQueryLanguage?: SearchConditionLanguage | null; - savedFilterValues?: Filter[] | null; + savedFilterValues?: Filter[]; }; export function useUpdateDashboard() { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 07b9f481..d829eabb 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -572,7 +572,7 @@ export const DashboardSchema = z.object({ filters: z.array(DashboardFilterSchema).optional(), savedQuery: z.string().nullable().optional(), savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(), - savedFilterValues: z.array(FilterSchema).nullable().optional(), + savedFilterValues: z.array(FilterSchema).optional(), }); export const DashboardWithoutIdSchema = DashboardSchema.omit({ id: true }); export type DashboardWithoutId = z.infer;