diff --git a/.changeset/slow-bananas-lay.md b/.changeset/slow-bananas-lay.md new file mode 100644 index 0000000000..6b4e1567c8 --- /dev/null +++ b/.changeset/slow-bananas-lay.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +--- + +feat: Add custom onClick field to external dashboards API diff --git a/packages/api/openapi.json b/packages/api/openapi.json index bfc26a5650..5fdf69b64c 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1354,6 +1354,10 @@ "description": "When true, render Group By columns to the left of series columns in the table. Defaults to false (Group By columns on the right).\n", "default": false, "example": false + }, + "onClick": { + "$ref": "#/components/schemas/OnClick", + "description": "Optional link-out configuration applied when a user clicks a row." } } }, @@ -1633,6 +1637,10 @@ ], "description": "Display as a table chart.", "example": "table" + }, + "onClick": { + "$ref": "#/components/schemas/OnClick", + "description": "Optional link-out configuration applied when a user clicks a row." } } } @@ -1720,6 +1728,184 @@ } } }, + "OnClickFilterTemplate": { + "type": "object", + "description": "A templated filter applied to the link-out destination. The rendered template value is combined with the expression as `expression IN (...)` on the destination search or dashboard. Multiple templates sharing the same expression are merged into a single IN clause.\n", + "required": [ + "kind", + "expression", + "template" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "expressionTemplate" + ], + "description": "Filter template kind. Currently only \"expressionTemplate\" is supported.", + "example": "expressionTemplate" + }, + "expression": { + "type": "string", + "minLength": 1, + "description": "The column/expression to filter the destination by (e.g. \"ServiceName\").", + "example": "ServiceName" + }, + "template": { + "type": "string", + "minLength": 1, + "description": "Value template rendered against the clicked row; supports row column variables in `{{column}}` form (e.g. `{{ServiceName}}`).\n", + "example": "{{ServiceName}}" + } + } + }, + "OnClickTarget": { + "description": "Identifies the source (for type=search) or dashboard (for type=dashboard) to link out to. Set mode to \"id\" to resolve a concrete ID, or \"template\" to resolve by rendered name at click time.\n", + "oneOf": [ + { + "type": "object", + "required": [ + "mode", + "id" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "id" + ], + "description": "Target is a single dashboard or log/trace source", + "example": "id" + }, + "id": { + "type": "string", + "description": "ID of the target source (for search) or dashboard (for dashboard).", + "example": "65f5e4a3b9e77c001a567890" + } + } + }, + { + "type": "object", + "required": [ + "mode", + "template" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "template" + ], + "description": "Target is matched by name against the template.", + "example": "template" + }, + "template": { + "type": "string", + "minLength": 1, + "description": "Name template rendered against the clicked row; supports `{{column}}` variables.\n", + "example": "{{ServiceName}}" + } + } + } + ], + "discriminator": { + "propertyName": "mode" + } + }, + "OnClickSearch": { + "type": "object", + "required": [ + "type", + "target" + ], + "description": "Link-out that navigates to the HyperDX search view.", + "properties": { + "type": { + "type": "string", + "enum": [ + "search" + ], + "description": "OnClick variant discriminator. Must be \"search\" for search link-outs.", + "example": "search" + }, + "target": { + "$ref": "#/components/schemas/OnClickTarget", + "description": "The source to navigate to." + }, + "whereTemplate": { + "type": "string", + "description": "Optional WHERE clause template applied to the destination search.", + "example": "ServiceName = '{{ServiceName}}'" + }, + "whereLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "description": "Language of the rendered whereTemplate." + }, + "filters": { + "type": "array", + "description": "Optional dashboard filter templates rendered against the clicked row.", + "items": { + "$ref": "#/components/schemas/OnClickFilterTemplate" + } + } + } + }, + "OnClickDashboard": { + "type": "object", + "required": [ + "type", + "target" + ], + "description": "Link-out that navigates to a HyperDX dashboard.", + "properties": { + "type": { + "type": "string", + "enum": [ + "dashboard" + ], + "description": "OnClick variant discriminator. Must be \"dashboard\" for dashboard link-outs.", + "example": "dashboard" + }, + "target": { + "$ref": "#/components/schemas/OnClickTarget", + "description": "The dashboard to navigate to." + }, + "whereTemplate": { + "type": "string", + "description": "Optional WHERE clause template applied to the destination dashboard.", + "example": "ServiceName = '{{ServiceName}}'" + }, + "whereLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "description": "Language of the rendered whereTemplate." + }, + "filters": { + "type": "array", + "description": "Optional dashboard filter templates rendered against the clicked row.", + "items": { + "$ref": "#/components/schemas/OnClickFilterTemplate" + } + } + } + }, + "OnClick": { + "description": "Link-out configuration applied when a user clicks a row of a table tile. Only table tiles (builder or raw SQL) currently support onClick. When target.mode is \"id\", the referenced source (type=search) or dashboard (type=dashboard) must already exist for the team.\n", + "oneOf": [ + { + "$ref": "#/components/schemas/OnClickSearch" + }, + { + "$ref": "#/components/schemas/OnClickDashboard" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "search": "#/components/schemas/OnClickSearch", + "dashboard": "#/components/schemas/OnClickDashboard" + } + } + }, "TableChartConfig": { "description": "Table chart. Omit configType for the builder variant (requires sourceId and select). Set configType to \"sql\" for the Raw SQL variant (requires connectionId and sqlTemplate).\n", "oneOf": [ 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 be2cd5f869..447680dd3d 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2256,6 +2256,22 @@ describe('External API v2 Dashboards - new format', () => { average: true, }, groupByColumnsOnLeft: true, + onClick: { + type: 'search', + target: { + mode: 'id', + id: traceSource._id.toString(), + }, + whereLanguage: 'sql', + whereTemplate: "ServiceName = '{{service.name}}'", + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: '{{service.name}}', + }, + ], + }, }, }; @@ -2379,6 +2395,15 @@ describe('External API v2 Dashboards - new format', () => { sqlTemplate, sourceId, numberFormat: { output: 'percent', mantissa: 1 }, + onClick: { + type: 'search', + target: { + mode: 'template', + template: '{{source_name}}', + }, + whereLanguage: 'lucene', + whereTemplate: 'ServiceName:"{{ServiceName}}"', + }, }, }; @@ -2515,6 +2540,238 @@ describe('External API v2 Dashboards - new format', () => { }); }); + it('should return 400 when a table tile onClick references a non-existent source', async () => { + const nonExistentSourceId = new ObjectId().toString(); + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with invalid onClick source', + tiles: [ + { + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: nonExistentSourceId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `Could not find the following source IDs: ${nonExistentSourceId}`, + }); + }); + + it('should return 400 when a table tile onClick search target references a metric source', async () => { + // The /search destination only supports log and trace sources. + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with metric onClick source', + tiles: [ + { + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { + mode: 'id', + id: metricSource._id.toString(), + }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `The following onClick search source IDs are not log or trace sources: ${metricSource._id.toString()}`, + }); + }); + + it('should return 400 when a table tile onClick references a non-existent dashboard', async () => { + const nonExistentDashboardId = new ObjectId().toString(); + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with invalid onClick dashboard', + tiles: [ + { + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: nonExistentDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `Could not find the following onClick dashboard IDs: ${nonExistentDashboardId}`, + }); + }); + + it('should return 400 when an onClick dashboard belongs to another team', async () => { + const otherTeamDashboard = await new Dashboard({ + name: 'Other Team Dashboard', + tiles: [], + team: new ObjectId(), + }).save(); + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard referencing cross-team dashboard', + tiles: [ + { + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'id', + id: otherTeamDashboard._id.toString(), + }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `Could not find the following onClick dashboard IDs: ${otherTeamDashboard._id.toString()}`, + }); + }); + + it('should accept a table tile onClick with valid id references', async () => { + const targetDashboard = await createTestDashboard({ + name: 'OnClick Target Dashboard', + }); + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with valid onClick references', + tiles: [ + { + name: 'Search Link Table', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: traceSource._id.toString() }, + whereLanguage: 'sql', + }, + }, + }, + { + name: 'Dashboard Link Table', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'id', + id: targetDashboard._id.toString(), + }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(200); + + expect(response.body.data.tiles[0].config.onClick).toEqual({ + type: 'search', + target: { mode: 'id', id: traceSource._id.toString() }, + whereLanguage: 'sql', + }); + expect(response.body.data.tiles[1].config.onClick).toEqual({ + type: 'dashboard', + target: { mode: 'id', id: targetDashboard._id.toString() }, + whereLanguage: 'sql', + }); + }); + + it('should return 400 when a table tile onClick target.id is not a valid ObjectId', async () => { + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with invalid onClick id', + tiles: [ + { + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: 'not-a-valid-object-id' }, + whereLanguage: 'sql', + }, + }, + }, + ], + }) + .expect(400); + + expect(response.body.message).toMatch(/Invalid|validation|id/i); + }); + it('should create a dashboard with filters', async () => { const dashboardPayload = { name: 'Dashboard with Filters', @@ -3093,6 +3350,15 @@ describe('External API v2 Dashboards - new format', () => { average: true, }, groupByColumnsOnLeft: true, + onClick: { + type: 'search', + target: { + mode: 'id', + id: traceSource._id.toString(), + }, + whereLanguage: 'sql', + whereTemplate: "ServiceName = '{{service.name}}'", + }, }, }; @@ -3229,6 +3495,14 @@ describe('External API v2 Dashboards - new format', () => { sqlTemplate, sourceId, numberFormat: { output: 'percent', mantissa: 1 }, + onClick: { + type: 'dashboard', + target: { + mode: 'template', + template: '{{dashboardName}}', + }, + whereLanguage: 'lucene', + }, }, }; @@ -3389,6 +3663,78 @@ describe('External API v2 Dashboards - new format', () => { }); }); + it('should return 400 on update when a table tile onClick references a non-existent dashboard', async () => { + const dashboard = await createTestDashboard(); + const nonExistentDashboardId = new ObjectId().toString(); + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send({ + name: 'Updated Dashboard', + tiles: [ + { + id: new ObjectId().toString(), + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: nonExistentDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + tags: [], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `Could not find the following onClick dashboard IDs: ${nonExistentDashboardId}`, + }); + }); + + it('should return 400 on update when a table tile onClick references a non-existent source', async () => { + const dashboard = await createTestDashboard(); + const nonExistentSourceId = new ObjectId().toString(); + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send({ + name: 'Updated Dashboard', + tiles: [ + { + id: new ObjectId().toString(), + name: 'Table Chart', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'table', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: nonExistentSourceId }, + whereLanguage: 'sql', + }, + }, + }, + ], + tags: [], + }) + .expect(400); + + expect(response.body).toEqual({ + message: `Could not find the following source IDs: ${nonExistentSourceId}`, + }); + }); + it('should delete alert when tile is updated from builder to raw SQL config and the display type does not support alerts', async () => { const tileId = new ObjectId().toString(); const dashboard = await createTestDashboard({ diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 22dcdd9e78..b1df19bb35 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -7,7 +7,6 @@ import { deleteDashboard } from '@/controllers/dashboard'; import { getSources } from '@/controllers/sources'; import Dashboard from '@/models/dashboard'; import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; -import logger from '@/utils/logger'; import { ExternalDashboardTileWithId, objectIdSchema } from '@/utils/zod'; import { @@ -16,11 +15,12 @@ import { convertExternalTilesToInternal, convertToExternalDashboard, createDashboardBodySchema, + getInvalidOnClickSearchSources, getMissingConnections, + getMissingOnClickDashboards, getMissingSources, isConfigTile, isRawSqlExternalTileConfig, - isSeriesTile, resolveSavedQueryLanguage, updateDashboardBodySchema, } from './utils/dashboards'; @@ -598,6 +598,9 @@ async function getSourceConnectionMismatches( * in the table. Defaults to false (Group By columns on the right). * default: false * example: false + * onClick: + * $ref: '#/components/schemas/OnClick' + * description: Optional link-out configuration applied when a user clicks a row. * * NumberBuilderChartConfig: * type: object @@ -801,6 +804,9 @@ async function getSourceConnectionMismatches( * enum: [table] * description: Display as a table chart. * example: "table" + * onClick: + * $ref: '#/components/schemas/OnClick' + * description: Optional link-out configuration applied when a user clicks a row. * * NumberRawSqlChartConfig: * description: Raw SQL configuration for a single big-number chart. @@ -856,6 +862,136 @@ async function getSourceConnectionMismatches( * mapping: * sql: '#/components/schemas/BarRawSqlChartConfig' * + * OnClickFilterTemplate: + * type: object + * description: > + * A templated filter applied to the link-out destination. The rendered + * template value is combined with the expression as `expression IN (...)` + * on the destination search or dashboard. Multiple templates sharing the + * same expression are merged into a single IN clause. + * required: [kind, expression, template] + * properties: + * kind: + * type: string + * enum: [expressionTemplate] + * description: Filter template kind. Currently only "expressionTemplate" is supported. + * example: "expressionTemplate" + * expression: + * type: string + * minLength: 1 + * description: The column/expression to filter the destination by (e.g. "ServiceName"). + * example: "ServiceName" + * template: + * type: string + * minLength: 1 + * description: > + * Value template rendered against the clicked row; supports row column + * variables in `{{column}}` form (e.g. `{{ServiceName}}`). + * example: "{{ServiceName}}" + * + * OnClickTarget: + * description: > + * Identifies the source (for type=search) or dashboard (for type=dashboard) + * to link out to. Set mode to "id" to resolve a concrete ID, or + * "template" to resolve by rendered name at click time. + * oneOf: + * - type: object + * required: [mode, id] + * properties: + * mode: + * type: string + * enum: [id] + * description: Target is a single dashboard or log/trace source + * example: "id" + * id: + * type: string + * description: ID of the target source (for search) or dashboard (for dashboard). + * example: "65f5e4a3b9e77c001a567890" + * - type: object + * required: [mode, template] + * properties: + * mode: + * type: string + * enum: [template] + * description: Target is matched by name against the template. + * example: "template" + * template: + * type: string + * minLength: 1 + * description: > + * Name template rendered against the clicked row; supports + * `{{column}}` variables. + * example: "{{ServiceName}}" + * discriminator: + * propertyName: mode + * + * OnClickSearch: + * type: object + * required: [type, target] + * description: Link-out that navigates to the HyperDX search view. + * properties: + * type: + * type: string + * enum: [search] + * description: OnClick variant discriminator. Must be "search" for search link-outs. + * example: "search" + * target: + * $ref: '#/components/schemas/OnClickTarget' + * description: The source to navigate to. + * whereTemplate: + * type: string + * description: Optional WHERE clause template applied to the destination search. + * example: "ServiceName = '{{ServiceName}}'" + * whereLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * description: Language of the rendered whereTemplate. + * filters: + * type: array + * description: Optional dashboard filter templates rendered against the clicked row. + * items: + * $ref: '#/components/schemas/OnClickFilterTemplate' + * + * OnClickDashboard: + * type: object + * required: [type, target] + * description: Link-out that navigates to a HyperDX dashboard. + * properties: + * type: + * type: string + * enum: [dashboard] + * description: OnClick variant discriminator. Must be "dashboard" for dashboard link-outs. + * example: "dashboard" + * target: + * $ref: '#/components/schemas/OnClickTarget' + * description: The dashboard to navigate to. + * whereTemplate: + * type: string + * description: Optional WHERE clause template applied to the destination dashboard. + * example: "ServiceName = '{{ServiceName}}'" + * whereLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * description: Language of the rendered whereTemplate. + * filters: + * type: array + * description: Optional dashboard filter templates rendered against the clicked row. + * items: + * $ref: '#/components/schemas/OnClickFilterTemplate' + * + * OnClick: + * description: > + * Link-out configuration applied when a user clicks a row of a table tile. + * Only table tiles (builder or raw SQL) currently support onClick. When + * target.mode is "id", the referenced source (type=search) or dashboard + * (type=dashboard) must already exist for the team. + * oneOf: + * - $ref: '#/components/schemas/OnClickSearch' + * - $ref: '#/components/schemas/OnClickDashboard' + * discriminator: + * propertyName: type + * mapping: + * search: '#/components/schemas/OnClickSearch' + * dashboard: '#/components/schemas/OnClickDashboard' + * * TableChartConfig: * description: > * Table chart. Omit configType for the builder variant (requires sourceId @@ -1597,12 +1733,19 @@ router.post( savedFilterValues, } = req.body; - const [missingSources, missingConnections, sourceConnectionMismatches] = - await Promise.all([ - getMissingSources(teamId, tiles, filters), - getMissingConnections(teamId, tiles), - getSourceConnectionMismatches(teamId, tiles), - ]); + const [ + missingSources, + missingConnections, + sourceConnectionMismatches, + missingOnClickDashboards, + invalidOnClickSearchSources, + ] = await Promise.all([ + getMissingSources(teamId, tiles, filters), + getMissingConnections(teamId, tiles), + getSourceConnectionMismatches(teamId, tiles), + getMissingOnClickDashboards(teamId, tiles), + getInvalidOnClickSearchSources(teamId, tiles), + ]); if (missingSources.length > 0) { return res.status(400).json({ message: `Could not find the following source IDs: ${missingSources.join( @@ -1624,6 +1767,20 @@ router.post( )}`, }); } + if (missingOnClickDashboards.length > 0) { + return res.status(400).json({ + message: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join( + ', ', + )}`, + }); + } + if (invalidOnClickSearchSources.length > 0) { + return res.status(400).json({ + message: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join( + ', ', + )}`, + }); + } const internalTiles = convertExternalTilesToInternal(tiles); const filtersWithIds = convertExternalFiltersToInternal(filters || []); @@ -1820,12 +1977,19 @@ router.put( savedFilterValues, } = req.body ?? {}; - const [missingSources, missingConnections, sourceConnectionMismatches] = - await Promise.all([ - getMissingSources(teamId, tiles, filters), - getMissingConnections(teamId, tiles), - getSourceConnectionMismatches(teamId, tiles), - ]); + const [ + missingSources, + missingConnections, + sourceConnectionMismatches, + missingOnClickDashboards, + invalidOnClickSearchSources, + ] = await Promise.all([ + getMissingSources(teamId, tiles, filters), + getMissingConnections(teamId, tiles), + getSourceConnectionMismatches(teamId, tiles), + getMissingOnClickDashboards(teamId, tiles), + getInvalidOnClickSearchSources(teamId, tiles), + ]); if (missingSources.length > 0) { return res.status(400).json({ message: `Could not find the following source IDs: ${missingSources.join( @@ -1847,6 +2011,20 @@ router.put( )}`, }); } + if (missingOnClickDashboards.length > 0) { + return res.status(400).json({ + message: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join( + ', ', + )}`, + }); + } + if (invalidOnClickSearchSources.length > 0) { + return res.status(400).json({ + message: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join( + ', ', + )}`, + }); + } const existingDashboard = await Dashboard.findOne( { _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 504cf85cbe..11eea33df4 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -4,6 +4,10 @@ import { AggregateFunctionSchema, BuilderSavedChartConfig, DisplayType, + isLogSource, + isOnClickDashboardById, + isOnClickSearchById, + isTraceSource, RawSqlSavedChartConfig, SavedChartConfig, } from '@hyperdx/common-utils/dist/types'; @@ -16,7 +20,7 @@ import { z } from 'zod'; import { deleteDashboardAlerts } from '@/controllers/alerts'; import { getConnectionsByTeam } from '@/controllers/connection'; import { getSources } from '@/controllers/sources'; -import { DashboardDocument } from '@/models/dashboard'; +import Dashboard, { DashboardDocument } from '@/models/dashboard'; import { translateExternalChartToTileConfig, translateExternalFilterToFilter, @@ -46,9 +50,7 @@ export type SeriesTile = ExternalDashboardTileWithId & { series: Exclude; }; -export function isSeriesTile( - tile: ExternalDashboardTileWithId, -): tile is SeriesTile { +function isSeriesTile(tile: ExternalDashboardTileWithId): tile is SeriesTile { return 'series' in tile && tile.series !== undefined; } @@ -144,6 +146,7 @@ const convertToExternalTileChartConfig = ( sqlTemplate: config.sqlTemplate, sourceId: config.source, numberFormat: config.numberFormat, + onClick: config.onClick, }; case DisplayType.Number: return { @@ -241,7 +244,12 @@ const convertToExternalTileChartConfig = ( }; case DisplayType.Table: return { - ...pick(config, ['having', 'numberFormat', 'groupByColumnsOnLeft']), + ...pick(config, [ + 'having', + 'numberFormat', + 'groupByColumnsOnLeft', + 'onClick', + ]), displayType: config.displayType, sourceId, asRatio: @@ -383,6 +391,10 @@ export function convertToInternalTileConfig( sqlTemplate: externalConfig.sqlTemplate, source: externalConfig.sourceId, numberFormat: externalConfig.numberFormat, + onClick: + externalConfig.displayType === 'table' + ? externalConfig.onClick + : undefined, } satisfies RawSqlSavedChartConfig; break; default: @@ -424,6 +436,7 @@ export function convertToInternalTileConfig( 'having', 'orderBy', 'groupByColumnsOnLeft', + 'onClick', ]), displayType: DisplayType.Table, select: externalConfig.select.map(convertToInternalSelectItem), @@ -498,6 +511,16 @@ export function convertToInternalTileConfig( // Shared dashboard validation helpers (used by both the REST router and MCP tools) // -------------------------------------------------------------------------------- +/** + * Extract the tile's onClick config, if the tile uses the new "config" format + * and the display type supports onClick (currently only table). + */ +function getTileOnClick(tile: ExternalDashboardTileWithId) { + if (!isConfigTile(tile)) return undefined; + if (!('onClick' in tile.config)) return undefined; + return tile.config.onClick; +} + /** Returns source IDs referenced in tiles/filters that do not exist for the team */ export async function getMissingSources( team: string | mongoose.Types.ObjectId, @@ -518,6 +541,12 @@ export async function getMissingSources( sourceIds.add(tile.config.sourceId); } } + + // Include source IDs referenced by OnClick link-outs (mode=id, type=search) + const onClick = getTileOnClick(tile); + if (isOnClickSearchById(onClick)) { + sourceIds.add(onClick.target.id); + } } if (filters?.length) { @@ -535,6 +564,65 @@ export async function getMissingSources( return [...sourceIds].filter(sourceId => !existingSourceIds.has(sourceId)); } +/** + * Returns source IDs referenced by onClick search link-outs (mode=id, + * type=search) whose source kind is not log or trace. The /search destination + * only supports log and trace sources, so linking to a metric/session source + * would produce a broken link at click time. + * + * Sources that don't exist are ignored here — getMissingSources handles that + * case separately with a clearer error message. + */ +export async function getInvalidOnClickSearchSources( + team: string | mongoose.Types.ObjectId, + tiles: ExternalDashboardTileWithId[], +): Promise { + const sourceIds = new Set(); + + for (const tile of tiles) { + const onClick = getTileOnClick(tile); + if (isOnClickSearchById(onClick)) { + sourceIds.add(onClick.target.id); + } + } + + if (sourceIds.size === 0) return []; + + const sources = await getSources(team.toString()); + const validSources = sources.filter(s => isLogSource(s) || isTraceSource(s)); + const validSourceIds = new Set(validSources.map(s => s._id.toString())); + return [...sourceIds].filter(id => !validSourceIds.has(id)); +} + +/** + * Returns dashboard IDs referenced by tile OnClick link-outs (mode=id, + * type=dashboard) that do not exist for the team. + */ +export async function getMissingOnClickDashboards( + team: string | mongoose.Types.ObjectId, + tiles: ExternalDashboardTileWithId[], +): Promise { + const dashboardIds = new Set(); + + for (const tile of tiles) { + const onClick = getTileOnClick(tile); + if (isOnClickDashboardById(onClick)) { + dashboardIds.add(onClick.target.id); + } + } + + if (dashboardIds.size === 0) return []; + + const existingDashboards = await Dashboard.find( + { team, _id: { $in: [...dashboardIds] } }, + { _id: 1 }, + ).lean(); + const existingDashboardIds = new Set( + existingDashboards.map(d => d._id.toString()), + ); + return [...dashboardIds].filter(id => !existingDashboardIds.has(id)); +} + /** Returns connection IDs referenced in tiles that do not belong to the team */ export async function getMissingConnections( team: string | mongoose.Types.ObjectId, diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 84181e5844..d40efb0d59 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -5,6 +5,8 @@ import { DashboardFilterSchema, MetricsDataType, NumberFormatSchema, + OnClickDashboardSchema, + OnClickSearchSchema, scheduleStartAtSchema, SearchConditionLanguageSchema as whereLanguageSchema, validateAlertScheduleOffsetMinutes, @@ -20,14 +22,6 @@ export const objectIdSchema = z.string().refine(val => { return Types.ObjectId.isValid(val); }); -const sourceTableSchema = z.union([ - z.literal('logs'), - z.literal('rrweb'), - z.literal('metrics'), -]); - -type SourceTable = z.infer; - // ================================ // Charts & Dashboards (old format) // ================================ @@ -168,6 +162,28 @@ export const externalQuantileLevelSchema = z.union([ z.literal(0.99), ]); +// ----------------------------------------------------- +// OnClick (link-out) schemas for table chart tiles +// ----------------------------------------------------- + +const externalOnClickTargetSchema = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('id'), id: objectIdSchema }), + z.object({ mode: z.literal('template'), template: z.string().min(1) }), +]); + +const externalOnClickSearchSchema = OnClickSearchSchema.extend({ + target: externalOnClickTargetSchema, +}); + +const externalOnClickDashboardSchema = OnClickDashboardSchema.extend({ + target: externalOnClickTargetSchema, +}); + +const externalOnClickSchema = z.discriminatedUnion('type', [ + externalOnClickSearchSchema, + externalOnClickDashboardSchema, +]); + const externalDashboardSelectItemSchema = z .object({ // For logs, traces, and metrics @@ -264,11 +280,13 @@ const externalDashboardTableChartConfigSchema = z.object({ asRatio: z.boolean().optional(), numberFormat: NumberFormatSchema.optional(), groupByColumnsOnLeft: z.boolean().optional(), + onClick: externalOnClickSchema.optional(), }); const externalDashboardTableRawSqlChartConfigSchema = externalDashboardRawSqlChartConfigBaseSchema.extend({ displayType: z.literal('table'), + onClick: externalOnClickSchema.optional(), }); const externalDashboardNumberRawSqlChartConfigSchema = diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index b2646ac76d..c8cfb1189c 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -680,7 +680,7 @@ const OnClickTargetSchema = z.discriminatedUnion('mode', [ ]); export type OnClickTarget = z.infer; -const OnClickSearchSchema = z.object({ +export const OnClickSearchSchema = z.object({ type: z.literal('search'), target: OnClickTargetSchema, whereTemplate: z.string().optional(), @@ -704,6 +704,36 @@ export const OnClickSchema = z.discriminatedUnion('type', [ ]); export type OnClick = z.infer; +export type OnClickSearchById = OnClickSearch & { + target: Extract; +}; + +export type OnClickDashboardById = OnClickDashboard & { + target: Extract; +}; + +/** True when the onClick links by concrete ID to a search source. */ +export function isOnClickSearchById( + onClick: OnClick | undefined, +): onClick is OnClickSearchById { + return ( + onClick !== undefined && + onClick.type === 'search' && + onClick.target.mode === 'id' + ); +} + +/** True when the onClick links by concrete ID to a dashboard. */ +export function isOnClickDashboardById( + onClick: OnClick | undefined, +): onClick is OnClickDashboardById { + return ( + onClick !== undefined && + onClick.type === 'dashboard' && + onClick.target.mode === 'id' + ); +} + // When making changes here, consider if they need to be made to the external API // schema as well (packages/api/src/utils/zod.ts). /**