feat: Add config property to external dashboard APIs. Deprecate series. (#1763)

This commit is contained in:
Drew Davis 2026-02-20 08:48:25 -05:00 committed by GitHub
parent 90a733aab8
commit b676f268d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 3807 additions and 505 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/api": minor
---
feat: Add config property to external dashboard APIs. Deprecate series.

View file

@ -527,6 +527,16 @@
],
"description": "Visual representation type for the time series."
},
"QuantileLevel": {
"type": "number",
"enum": [
0.5,
0.9,
0.95,
0.99
],
"description": "Percentile level; only valid when aggFn is \"quantile\"."
},
"SortOrder": {
"type": "string",
"enum": [
@ -904,16 +914,375 @@
}
}
},
"TileInput": {
"SelectItem": {
"type": "object",
"description": "Dashboard tile/chart configuration for creation",
"required": [
"aggFn"
],
"description": "A single aggregated value to compute. The valueExpression must be omitted when aggFn is \"count\", and required for all other functions. The level field may only be used with aggFn \"quantile\".\n",
"properties": {
"aggFn": {
"$ref": "#/components/schemas/AggregationFunction",
"description": "Aggregation function to apply. \"count\" does not require a valueExpression; \"quantile\" requires a level field indicating the desired percentile (e.g., 0.95).\n",
"example": "count"
},
"valueExpression": {
"type": "string",
"maxLength": 10000,
"description": "Expression for the column or value to aggregate. Must be omitted when aggFn is \"count\"; required for all other aggFn values.\n",
"example": "Duration"
},
"alias": {
"type": "string",
"maxLength": 10000,
"description": "Display alias for this select item in chart legends.",
"example": "Request Duration"
},
"level": {
"$ref": "#/components/schemas/QuantileLevel"
},
"where": {
"type": "string",
"maxLength": 10000,
"description": "SQL or Lucene filter condition applied before aggregation.",
"default": "",
"example": "service:api"
},
"whereLanguage": {
"$ref": "#/components/schemas/QueryLanguage"
},
"metricName": {
"type": "string",
"description": "Name of the metric to aggregate; only applicable when the source is a metrics source."
},
"metricType": {
"$ref": "#/components/schemas/MetricDataType",
"description": "Metric type; only applicable when the source is a metrics source."
},
"periodAggFn": {
"type": "string",
"enum": [
"delta"
],
"description": "Optional period aggregation function for Gauge metrics (e.g., compute the delta over the period)."
}
}
},
"LineChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select"
],
"description": "Configuration for a line time-series chart.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"line"
],
"example": "line"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"description": "One or more aggregated values to plot. When asRatio is true, exactly two select items are required.\n",
"items": {
"$ref": "#/components/schemas/SelectItem"
}
},
"groupBy": {
"type": "string",
"description": "Field expression to group results by (creates separate lines per group value).",
"example": "host",
"maxLength": 10000
},
"asRatio": {
"type": "boolean",
"description": "Plot select[0] / select[1] as a ratio. Requires exactly two select items.",
"default": false
},
"alignDateRangeToGranularity": {
"type": "boolean",
"description": "Expand date range boundaries to the query granularity interval.",
"default": true
},
"fillNulls": {
"type": "boolean",
"description": "Fill missing time buckets with zero instead of leaving gaps.",
"default": true
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat"
},
"compareToPreviousPeriod": {
"type": "boolean",
"description": "Overlay the equivalent previous time period for comparison.",
"default": false
}
}
},
"BarChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select"
],
"description": "Configuration for a stacked-bar time-series chart.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"stacked_bar"
],
"example": "stacked_bar"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"description": "One or more aggregated values to plot. When asRatio is true, exactly two select items are required.\n",
"items": {
"$ref": "#/components/schemas/SelectItem"
}
},
"groupBy": {
"type": "string",
"description": "Field expression to group results by (creates separate bars segments per group value).",
"example": "service",
"maxLength": 10000
},
"asRatio": {
"type": "boolean",
"description": "Plot select[0] / select[1] as a ratio. Requires exactly two select items.",
"default": false
},
"alignDateRangeToGranularity": {
"type": "boolean",
"description": "Align the date range boundaries to the query granularity interval.",
"default": true
},
"fillNulls": {
"type": "boolean",
"description": "Fill missing time buckets with zero instead of leaving gaps.",
"default": true
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat"
}
}
},
"TableChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select"
],
"description": "Configuration for a table aggregation chart.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"table"
],
"example": "table"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"description": "One or more aggregated values to display as table columns. When asRatio is true, exactly two select items are required.\n",
"items": {
"$ref": "#/components/schemas/SelectItem"
}
},
"groupBy": {
"type": "string",
"maxLength": 10000,
"description": "Field expression to group results by (one row per group value).",
"example": "service"
},
"having": {
"type": "string",
"maxLength": 10000,
"description": "Post-aggregation SQL HAVING condition.",
"example": "count > 100"
},
"orderBy": {
"type": "string",
"maxLength": 10000,
"description": "SQL ORDER BY expression for sorting table rows.",
"example": "count DESC"
},
"asRatio": {
"type": "boolean",
"description": "Display select[0] / select[1] as a ratio. Requires exactly two select items.",
"example": false
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat"
}
}
},
"NumberChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select"
],
"description": "Configuration for a single big-number chart.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"number"
],
"example": "number"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"description": "Exactly one aggregated value to display as a single number.",
"items": {
"$ref": "#/components/schemas/SelectItem"
}
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat"
}
}
},
"SearchChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select",
"whereLanguage"
],
"description": "Configuration for a raw-event search / log viewer tile.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"search"
],
"example": "search"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "string",
"maxLength": 10000,
"description": "Comma-separated list of expressions to display.",
"example": "timestamp, level, message"
},
"where": {
"type": "string",
"maxLength": 10000,
"description": "Filter condition for the search (syntax depends on whereLanguage).",
"default": "",
"example": "level:error"
},
"whereLanguage": {
"$ref": "#/components/schemas/QueryLanguage"
}
}
},
"MarkdownChartConfig": {
"type": "object",
"required": [
"displayType"
],
"description": "Configuration for a freeform Markdown text tile.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"markdown"
],
"example": "markdown"
},
"markdown": {
"type": "string",
"maxLength": 50000,
"description": "Markdown content to render inside the tile.",
"example": "# Dashboard Title\n\nThis is a markdown widget."
}
}
},
"TileConfig": {
"description": "Tile chart configuration. The displayType field determines which variant is used.\n",
"oneOf": [
{
"$ref": "#/components/schemas/LineChartConfig"
},
{
"$ref": "#/components/schemas/BarChartConfig"
},
{
"$ref": "#/components/schemas/TableChartConfig"
},
{
"$ref": "#/components/schemas/NumberChartConfig"
},
{
"$ref": "#/components/schemas/SearchChartConfig"
},
{
"$ref": "#/components/schemas/MarkdownChartConfig"
}
],
"discriminator": {
"propertyName": "displayType",
"mapping": {
"line": "#/components/schemas/LineChartConfig",
"stacked_bar": "#/components/schemas/BarChartConfig",
"table": "#/components/schemas/TableChartConfig",
"number": "#/components/schemas/NumberChartConfig",
"search": "#/components/schemas/SearchChartConfig",
"markdown": "#/components/schemas/MarkdownChartConfig"
}
}
},
"TileBase": {
"type": "object",
"description": "Common fields shared by tile input and output",
"required": [
"name",
"x",
"y",
"w",
"h",
"series"
"h"
],
"properties": {
"name": {
@ -947,25 +1316,17 @@
"description": "Height in grid units",
"example": 3
},
"asRatio": {
"type": "boolean",
"description": "Display two series as a ratio (series[0] / series[1])",
"example": false
},
"series": {
"type": "array",
"minItems": 1,
"description": "Data series to display in this tile (all must be the same type)",
"items": {
"$ref": "#/components/schemas/DashboardChartSeries"
}
"config": {
"$ref": "#/components/schemas/TileConfig",
"description": "Chart configuration for the tile. The displayType field determines which variant is used. Replaces the deprecated \"series\" and \"asRatio\" fields."
}
}
},
"Tile": {
"TileOutput": {
"description": "Response format for dashboard tiles",
"allOf": [
{
"$ref": "#/components/schemas/TileInput"
"$ref": "#/components/schemas/TileBase"
},
{
"type": "object",
@ -982,6 +1343,40 @@
}
]
},
"TileInput": {
"description": "Input / request format when creating or updating tiles. The id field is optional: on create it is ignored (the server always assigns a new ID); on update, a matching id is used to identify the existing tile to preserve — tiles whose id does not match an existing tile are assigned a new generated ID.\n",
"allOf": [
{
"$ref": "#/components/schemas/TileBase"
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 36,
"description": "Optional tile ID. Omit to generate a new ID.",
"example": "65f5e4a3b9e77c001a901234"
},
"asRatio": {
"type": "boolean",
"description": "Display two series as a ratio (series[0] / series[1]). Only applicable when providing \"series\". Deprecated in favor of \"config.asRatio\".",
"example": false,
"deprecated": true
},
"series": {
"type": "array",
"minItems": 1,
"description": "Data series to display in this tile (all must be the same type). Deprecated; use \"config\" instead.",
"deprecated": true,
"items": {
"$ref": "#/components/schemas/DashboardChartSeries"
}
}
}
}
]
},
"FilterInput": {
"type": "object",
"description": "Dashboard filter key that can be added to a dashboard",
@ -1063,7 +1458,7 @@
"type": "array",
"description": "List of tiles/charts in the dashboard",
"items": {
"$ref": "#/components/schemas/Tile"
"$ref": "#/components/schemas/TileOutput"
}
},
"tags": {
@ -1141,9 +1536,9 @@
"tiles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Tile"
"$ref": "#/components/schemas/TileInput"
},
"description": "Tiles must include their IDs for updates. To add a new tile, generate a unique ID (max 36 chars)."
"description": "Full list of tiles for the dashboard. Existing tiles are matched by ID; tiles with an ID that does not match an existing tile will be assigned a new generated ID."
},
"tags": {
"type": "array",
@ -2432,17 +2827,17 @@
"y": 0,
"w": 6,
"h": 3,
"asRatio": false,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "avg",
"field": "cpu.usage",
"where": "host:server-01",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "avg",
"valueExpression": "cpu.usage",
"where": "host:server-01"
}
]
}
}
],
"tags": [
@ -2470,18 +2865,18 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "table",
"sourceId": "65f5e4a3b9e77c001a111112",
"aggFn": "count",
"where": "level:error",
"groupBy": [
"service"
],
"sortOrder": "desc"
}
]
"config": {
"displayType": "table",
"sourceId": "65f5e4a3b9e77c001a111112",
"select": [
{
"aggFn": "count",
"where": "level:error"
}
],
"groupBy": "service",
"orderBy": "count DESC"
}
}
],
"tags": [
@ -2526,7 +2921,7 @@
},
"examples": {
"simpleTimeSeriesDashboard": {
"summary": "Dashboard with time series chart",
"summary": "Dashboard with a line chart",
"value": {
"name": "API Monitoring Dashboard",
"tiles": [
@ -2536,15 +2931,16 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "service:api",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "service:api"
}
]
}
}
],
"tags": [
@ -2572,15 +2968,16 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "service:backend",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "service:backend"
}
]
}
},
{
"name": "Error Distribution",
@ -2588,18 +2985,18 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "table",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "level:error",
"groupBy": [
"errorType"
],
"sortOrder": "desc"
}
]
"config": {
"displayType": "table",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "level:error"
}
],
"groupBy": "errorType",
"orderBy": "count DESC"
}
}
],
"tags": [
@ -2643,15 +3040,16 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "service:api",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "service:api"
}
]
}
}
],
"tags": [
@ -2759,17 +3157,17 @@
"y": 0,
"w": 6,
"h": 3,
"asRatio": false,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "avg",
"field": "cpu.usage",
"where": "host:server-01",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "avg",
"valueExpression": "cpu.usage",
"where": "host:server-01"
}
]
}
},
{
"id": "65f5e4a3b9e77c001a901235",
@ -2778,17 +3176,17 @@
"y": 0,
"w": 6,
"h": 3,
"asRatio": false,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "avg",
"field": "memory.usage",
"where": "host:server-01",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "avg",
"valueExpression": "memory.usage",
"where": "host:server-01"
}
]
}
}
],
"tags": [
@ -2873,20 +3271,21 @@
"tiles": [
{
"id": "65f5e4a3b9e77c001a901234",
"name": "Updated Time Series Chart",
"name": "Updated Line Chart",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "level:error",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "level:error"
}
]
}
},
{
"id": "new-tile-123",
@ -2895,14 +3294,16 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "number",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "level:info"
}
]
"config": {
"displayType": "number",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "level:info"
}
]
}
}
],
"tags": [
@ -2942,20 +3343,21 @@
"tiles": [
{
"id": "65f5e4a3b9e77c001a901234",
"name": "Updated Time Series Chart",
"name": "Updated Line Chart",
"x": 0,
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "time",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "level:error",
"groupBy": []
}
]
"config": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "level:error"
}
]
}
},
{
"id": "new-tile-123",
@ -2964,14 +3366,16 @@
"y": 0,
"w": 6,
"h": 3,
"series": [
{
"type": "number",
"sourceId": "65f5e4a3b9e77c001a111111",
"aggFn": "count",
"where": "level:info"
}
]
"config": {
"displayType": "number",
"sourceId": "65f5e4a3b9e77c001a111111",
"select": [
{
"aggFn": "count",
"where": "level:info"
}
]
}
}
],
"tags": [

View file

@ -18,6 +18,8 @@ import Server from '@/server';
import logger from '@/utils/logger';
import { MetricModel } from '@/utils/logParser';
import { ExternalDashboardTile } from './utils/zod';
const MOCK_USER = {
email: 'fake@deploysentinel.com',
password: 'TacoCat!2#4X',
@ -614,6 +616,26 @@ export const makeExternalChart = (opts?: {
],
});
export const makeExternalTile = (opts?: {
sourceId?: string;
}): ExternalDashboardTile => ({
name: 'Test Chart',
x: 1,
y: 1,
w: 1,
h: 1,
config: {
displayType: 'line',
sourceId: opts?.sourceId ?? '68dd82484f54641b08667897',
select: [
{
aggFn: 'count',
where: '',
},
],
},
});
export const makeAlertInput = ({
dashboardId,
interval = '15m',

View file

@ -9,7 +9,6 @@ import { getSources } from '@/controllers/sources';
import Dashboard from '@/models/dashboard';
import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
import {
translateDashboardDocumentToExternalDashboard,
translateExternalChartToTileConfig,
translateExternalFilterToFilter,
} from '@/utils/externalApi';
@ -18,13 +17,19 @@ import {
externalDashboardFilterSchema,
externalDashboardFilterSchemaWithId,
ExternalDashboardFilterWithId,
externalDashboardTileSchema,
externalDashboardTileSchemaWithId,
externalDashboardTileListSchema,
ExternalDashboardTileWithId,
objectIdSchema,
tagsSchema,
} from '@/utils/zod';
import {
convertToExternalDashboard,
convertToInternalTileConfig,
isConfigTile,
isSeriesTile,
} from './utils/dashboards';
/** Returns an array of source IDs that are referenced in the tiles/filters but do not exist in the team's sources */
async function getMissingSources(
team: string | mongoose.Types.ObjectId,
@ -32,13 +37,21 @@ async function getMissingSources(
filters?: (ExternalDashboardFilter | ExternalDashboardFilterWithId)[],
): Promise<string[]> {
const sourceIds = new Set<string>();
for (const tile of tiles) {
for (const series of tile.series) {
if ('sourceId' in series) {
sourceIds.add(series.sourceId);
if (isSeriesTile(tile)) {
for (const series of tile.series) {
if ('sourceId' in series) {
sourceIds.add(series.sourceId);
}
}
} else if (isConfigTile(tile)) {
if ('sourceId' in tile.config) {
sourceIds.add(tile.config.sourceId);
}
}
}
if (filters?.length) {
for (const filter of filters) {
if ('sourceId' in filter) {
@ -78,6 +91,10 @@ async function getMissingSources(
* type: string
* enum: [stacked_bar, line]
* description: Visual representation type for the time series.
* QuantileLevel:
* type: number
* enum: [0.5, 0.90, 0.95, 0.99]
* description: Percentile level; only valid when aggFn is "quantile".
* SortOrder:
* type: string
* enum: [desc, asc]
@ -354,16 +371,297 @@ async function getMissingSources(
* search: '#/components/schemas/SearchChartSeries'
* markdown: '#/components/schemas/MarkdownChartSeries'
*
* TileInput:
* SelectItem:
* type: object
* description: Dashboard tile/chart configuration for creation
* required:
* - aggFn
* description: >
* A single aggregated value to compute. The valueExpression must be
* omitted when aggFn is "count", and required for all other functions.
* The level field may only be used with aggFn "quantile".
* properties:
* aggFn:
* $ref: '#/components/schemas/AggregationFunction'
* description: >
* Aggregation function to apply. "count" does not require a valueExpression; "quantile" requires a level field indicating the desired percentile (e.g., 0.95).
* example: "count"
* valueExpression:
* type: string
* maxLength: 10000
* description: >
* Expression for the column or value to aggregate. Must be omitted when
* aggFn is "count"; required for all other aggFn values.
* example: "Duration"
* alias:
* type: string
* maxLength: 10000
* description: Display alias for this select item in chart legends.
* example: "Request Duration"
* level:
* $ref: '#/components/schemas/QuantileLevel'
* where:
* type: string
* maxLength: 10000
* description: SQL or Lucene filter condition applied before aggregation.
* default: ""
* example: "service:api"
* whereLanguage:
* $ref: '#/components/schemas/QueryLanguage'
* metricName:
* type: string
* description: Name of the metric to aggregate; only applicable when the source is a metrics source.
* metricType:
* $ref: '#/components/schemas/MetricDataType'
* description: Metric type; only applicable when the source is a metrics source.
* periodAggFn:
* type: string
* enum: [delta]
* description: Optional period aggregation function for Gauge metrics (e.g., compute the delta over the period).
*
* LineChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* description: Configuration for a line time-series chart.
* properties:
* displayType:
* type: string
* enum: [line]
* example: "line"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: array
* minItems: 1
* maxItems: 20
* description: >
* One or more aggregated values to plot. When asRatio is true,
* exactly two select items are required.
* items:
* $ref: '#/components/schemas/SelectItem'
* groupBy:
* type: string
* description: Field expression to group results by (creates separate lines per group value).
* example: "host"
* maxLength: 10000
* asRatio:
* type: boolean
* description: Plot select[0] / select[1] as a ratio. Requires exactly two select items.
* default: false
* alignDateRangeToGranularity:
* type: boolean
* description: Expand date range boundaries to the query granularity interval.
* default: true
* fillNulls:
* type: boolean
* description: Fill missing time buckets with zero instead of leaving gaps.
* default: true
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
* compareToPreviousPeriod:
* type: boolean
* description: Overlay the equivalent previous time period for comparison.
* default: false
*
* BarChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* description: Configuration for a stacked-bar time-series chart.
* properties:
* displayType:
* type: string
* enum: [stacked_bar]
* example: "stacked_bar"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: array
* minItems: 1
* maxItems: 20
* description: >
* One or more aggregated values to plot. When asRatio is true,
* exactly two select items are required.
* items:
* $ref: '#/components/schemas/SelectItem'
* groupBy:
* type: string
* description: Field expression to group results by (creates separate bars segments per group value).
* example: "service"
* maxLength: 10000
* asRatio:
* type: boolean
* description: Plot select[0] / select[1] as a ratio. Requires exactly two select items.
* default: false
* alignDateRangeToGranularity:
* type: boolean
* description: Align the date range boundaries to the query granularity interval.
* default: true
* fillNulls:
* type: boolean
* description: Fill missing time buckets with zero instead of leaving gaps.
* default: true
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
*
* TableChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* description: Configuration for a table aggregation chart.
* properties:
* displayType:
* type: string
* enum: [table]
* example: "table"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: array
* minItems: 1
* maxItems: 20
* description: >
* One or more aggregated values to display as table columns.
* When asRatio is true, exactly two select items are required.
* items:
* $ref: '#/components/schemas/SelectItem'
* groupBy:
* type: string
* maxLength: 10000
* description: Field expression to group results by (one row per group value).
* example: "service"
* having:
* type: string
* maxLength: 10000
* description: Post-aggregation SQL HAVING condition.
* example: "count > 100"
* orderBy:
* type: string
* maxLength: 10000
* description: SQL ORDER BY expression for sorting table rows.
* example: "count DESC"
* asRatio:
* type: boolean
* description: Display select[0] / select[1] as a ratio. Requires exactly two select items.
* example: false
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
*
* NumberChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* description: Configuration for a single big-number chart.
* properties:
* displayType:
* type: string
* enum: [number]
* example: "number"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: array
* minItems: 1
* maxItems: 1
* description: Exactly one aggregated value to display as a single number.
* items:
* $ref: '#/components/schemas/SelectItem'
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
*
* SearchChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* - whereLanguage
* description: Configuration for a raw-event search / log viewer tile.
* properties:
* displayType:
* type: string
* enum: [search]
* example: "search"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: string
* maxLength: 10000
* description: Comma-separated list of expressions to display.
* example: "timestamp, level, message"
* where:
* type: string
* maxLength: 10000
* description: Filter condition for the search (syntax depends on whereLanguage).
* default: ""
* example: "level:error"
* whereLanguage:
* $ref: '#/components/schemas/QueryLanguage'
*
* MarkdownChartConfig:
* type: object
* required:
* - displayType
* description: Configuration for a freeform Markdown text tile.
* properties:
* displayType:
* type: string
* enum: [markdown]
* example: "markdown"
* markdown:
* type: string
* maxLength: 50000
* description: Markdown content to render inside the tile.
* example: "# Dashboard Title\n\nThis is a markdown widget."
*
* TileConfig:
* description: >
* Tile chart configuration. The displayType field determines which
* variant is used.
* oneOf:
* - $ref: '#/components/schemas/LineChartConfig'
* - $ref: '#/components/schemas/BarChartConfig'
* - $ref: '#/components/schemas/TableChartConfig'
* - $ref: '#/components/schemas/NumberChartConfig'
* - $ref: '#/components/schemas/SearchChartConfig'
* - $ref: '#/components/schemas/MarkdownChartConfig'
* discriminator:
* propertyName: displayType
* mapping:
* line: '#/components/schemas/LineChartConfig'
* stacked_bar: '#/components/schemas/BarChartConfig'
* table: '#/components/schemas/TableChartConfig'
* number: '#/components/schemas/NumberChartConfig'
* search: '#/components/schemas/SearchChartConfig'
* markdown: '#/components/schemas/MarkdownChartConfig'
*
* TileBase:
* type: object
* description: Common fields shared by tile input and output
* required:
* - name
* - x
* - y
* - w
* - h
* - series
* properties:
* name:
* type: string
@ -391,20 +689,14 @@ async function getMissingSources(
* minimum: 1
* description: Height in grid units
* example: 3
* asRatio:
* type: boolean
* description: Display two series as a ratio (series[0] / series[1])
* example: false
* series:
* type: array
* minItems: 1
* description: Data series to display in this tile (all must be the same type)
* items:
* $ref: '#/components/schemas/DashboardChartSeries'
* config:
* $ref: '#/components/schemas/TileConfig'
* description: Chart configuration for the tile. The displayType field determines which variant is used. Replaces the deprecated "series" and "asRatio" fields.
*
* Tile:
* TileOutput:
* description: Response format for dashboard tiles
* allOf:
* - $ref: '#/components/schemas/TileInput'
* - $ref: '#/components/schemas/TileBase'
* - type: object
* required:
* - id
@ -414,6 +706,35 @@ async function getMissingSources(
* maxLength: 36
* example: "65f5e4a3b9e77c001a901234"
*
* TileInput:
* description: >
* Input / request format when creating or updating tiles. The id field is
* optional: on create it is ignored (the server always assigns a new ID);
* on update, a matching id is used to identify the existing tile to
* preserve tiles whose id does not match an existing tile are assigned
* a new generated ID.
* allOf:
* - $ref: '#/components/schemas/TileBase'
* - type: object
* properties:
* id:
* type: string
* maxLength: 36
* description: Optional tile ID. Omit to generate a new ID.
* example: "65f5e4a3b9e77c001a901234"
* asRatio:
* type: boolean
* description: Display two series as a ratio (series[0] / series[1]). Only applicable when providing "series". Deprecated in favor of "config.asRatio".
* example: false
* deprecated: true
* series:
* type: array
* minItems: 1
* description: Data series to display in this tile (all must be the same type). Deprecated; use "config" instead.
* deprecated: true
* items:
* $ref: '#/components/schemas/DashboardChartSeries'
*
* FilterInput:
* type: object
* description: Dashboard filter key that can be added to a dashboard
@ -470,7 +791,7 @@ async function getMissingSources(
* type: array
* description: List of tiles/charts in the dashboard
* items:
* $ref: '#/components/schemas/Tile'
* $ref: '#/components/schemas/TileOutput'
* tags:
* type: array
* description: Tags for organizing and filtering dashboards
@ -525,8 +846,8 @@ async function getMissingSources(
* tiles:
* type: array
* items:
* $ref: '#/components/schemas/Tile'
* description: Tiles must include their IDs for updates. To add a new tile, generate a unique ID (max 36 chars).
* $ref: '#/components/schemas/TileInput'
* description: Full list of tiles for the dashboard. Existing tiles are matched by ID; tiles with an ID that does not match an existing tile will be assigned a new generated ID.
* tags:
* type: array
* items:
@ -590,14 +911,13 @@ const router = express.Router();
* y: 0
* w: 6
* h: 3
* asRatio: false
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "avg"
* field: "cpu.usage"
* where: "host:server-01"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "avg"
* valueExpression: "cpu.usage"
* where: "host:server-01"
* tags: ["infrastructure", "monitoring"]
* filters:
* - id: "65f5e4a3b9e77c001a301001"
@ -614,13 +934,14 @@ const router = express.Router();
* y: 0
* w: 6
* h: 3
* series:
* - type: "table"
* sourceId: "65f5e4a3b9e77c001a111112"
* aggFn: "count"
* where: "level:error"
* groupBy: ["service"]
* sortOrder: "desc"
* config:
* displayType: "table"
* sourceId: "65f5e4a3b9e77c001a111112"
* select:
* - aggFn: "count"
* where: "level:error"
* groupBy: "service"
* orderBy: "count DESC"
* tags: ["api", "monitoring"]
* filters:
* - id: "65f5e4a3b9e77c001a301002"
@ -644,9 +965,7 @@ router.get('/', async (req, res, next) => {
).sort({ name: -1 });
res.json({
data: dashboards.map(d =>
translateDashboardDocumentToExternalDashboard(d),
),
data: dashboards.map(d => convertToExternalDashboard(d)),
});
} catch (e) {
next(e);
@ -690,28 +1009,26 @@ router.get('/', async (req, res, next) => {
* y: 0
* w: 6
* h: 3
* asRatio: false
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "avg"
* field: "cpu.usage"
* where: "host:server-01"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "avg"
* valueExpression: "cpu.usage"
* where: "host:server-01"
* - id: "65f5e4a3b9e77c001a901235"
* name: "Memory Usage"
* x: 6
* y: 0
* w: 6
* h: 3
* asRatio: false
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "avg"
* field: "memory.usage"
* where: "host:server-01"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "avg"
* valueExpression: "memory.usage"
* where: "host:server-01"
* tags: ["infrastructure", "monitoring"]
* filters:
* - id: "65f5e4a3b9e77c001a301003"
@ -760,7 +1077,7 @@ router.get(
}
res.json({
data: translateDashboardDocumentToExternalDashboard(dashboard),
data: convertToExternalDashboard(dashboard),
});
} catch (e) {
next(e);
@ -784,7 +1101,7 @@ router.get(
* $ref: '#/components/schemas/CreateDashboardRequest'
* examples:
* simpleTimeSeriesDashboard:
* summary: Dashboard with time series chart
* summary: Dashboard with a line chart
* value:
* name: "API Monitoring Dashboard"
* tiles:
@ -793,12 +1110,12 @@ router.get(
* y: 0
* w: 6
* h: 3
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "service:api"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "service:api"
* tags: ["api", "monitoring"]
* filters:
* - type: "QUERY_EXPRESSION"
@ -815,24 +1132,25 @@ router.get(
* y: 0
* w: 6
* h: 3
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "service:backend"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "service:backend"
* - name: "Error Distribution"
* x: 6
* y: 0
* w: 6
* h: 3
* series:
* - type: "table"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "level:error"
* groupBy: ["errorType"]
* sortOrder: "desc"
* config:
* displayType: "table"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "level:error"
* groupBy: "errorType"
* orderBy: "count DESC"
* tags: ["service-health", "production"]
* filters:
* - type: "QUERY_EXPRESSION"
@ -860,12 +1178,12 @@ router.get(
* y: 0
* w: 6
* h: 3
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "service:api"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "service:api"
* tags: ["api", "monitoring"]
* filters:
* - id: "65f5e4a3b9e77c001a301004"
@ -903,7 +1221,7 @@ router.post(
validateRequest({
body: z.object({
name: z.string().max(1024),
tiles: z.array(externalDashboardTileSchema),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
filters: z.array(externalDashboardFilterSchema).optional(),
}),
@ -926,11 +1244,18 @@ router.post(
});
}
const charts = tiles.map(tile => {
const chartId = new ObjectId().toString();
const internalTiles = tiles.map(tile => {
const tileId = new ObjectId().toString();
if (isConfigTile(tile)) {
return convertToInternalTileConfig({
...tile,
id: tileId,
});
}
return translateExternalChartToTileConfig({
...tile,
id: chartId,
id: tileId,
});
});
@ -943,14 +1268,14 @@ router.post(
const newDashboard = await new Dashboard({
name,
tiles: charts,
tiles: internalTiles,
tags: tags && uniq(tags),
filters: filtersWithIds,
team: teamId,
}).save();
res.json({
data: translateDashboardDocumentToExternalDashboard(newDashboard),
data: convertToExternalDashboard(newDashboard),
});
} catch (e) {
next(e);
@ -987,28 +1312,29 @@ router.post(
* name: "Updated Dashboard Name"
* tiles:
* - id: "65f5e4a3b9e77c001a901234"
* name: "Updated Time Series Chart"
* name: "Updated Line Chart"
* x: 0
* y: 0
* w: 6
* h: 3
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "level:error"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "level:error"
* - id: "new-tile-123"
* name: "New Number Chart"
* x: 6
* y: 0
* w: 6
* h: 3
* series:
* - type: "number"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "level:info"
* config:
* displayType: "number"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "level:info"
* tags: ["production", "updated"]
* filters:
* - id: "65f5e4a3b9e77c001a301005"
@ -1032,28 +1358,29 @@ router.post(
* name: "Updated Dashboard Name"
* tiles:
* - id: "65f5e4a3b9e77c001a901234"
* name: "Updated Time Series Chart"
* name: "Updated Line Chart"
* x: 0
* y: 0
* w: 6
* h: 3
* series:
* - type: "time"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "level:error"
* groupBy: []
* config:
* displayType: "line"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "level:error"
* - id: "new-tile-123"
* name: "New Number Chart"
* x: 6
* y: 0
* w: 6
* h: 3
* series:
* - type: "number"
* sourceId: "65f5e4a3b9e77c001a111111"
* aggFn: "count"
* where: "level:info"
* config:
* displayType: "number"
* sourceId: "65f5e4a3b9e77c001a111111"
* select:
* - aggFn: "count"
* where: "level:info"
* tags: ["production", "updated"]
* filters:
* - id: "65f5e4a3b9e77c001a301005"
@ -1102,7 +1429,7 @@ router.put(
}),
body: z.object({
name: z.string().max(1024),
tiles: z.array(externalDashboardTileSchemaWithId),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
filters: z.array(externalDashboardFilterSchemaWithId).optional(),
}),
@ -1129,16 +1456,44 @@ router.put(
});
}
// Convert external tiles to internal charts format
const charts = tiles.map(translateExternalChartToTileConfig);
const existingDashboard = await Dashboard.findOne(
{ _id: dashboardId, team: teamId },
{ tiles: 1, filters: 1 },
).lean();
const existingTileIds = new Set(
(existingDashboard?.tiles ?? []).map((t: { id: string }) => t.id),
);
const existingFilterIds = new Set(
(existingDashboard?.filters ?? []).map((f: { id: string }) => f.id),
);
// Convert external tiles to internal charts format.
// Generate a new id for any tile whose id doesn't match an existing tile.
const internalTiles = tiles.map(tile => {
const tileId = existingTileIds.has(tile.id)
? tile.id
: new ObjectId().toString();
if (isConfigTile(tile)) {
return convertToInternalTileConfig({ ...tile, id: tileId });
}
return translateExternalChartToTileConfig({ ...tile, id: tileId });
});
const setPayload: Record<string, unknown> = {
name,
tiles: charts,
tiles: internalTiles,
tags: tags && uniq(tags),
};
if (filters !== undefined) {
setPayload.filters = filters.map(translateExternalFilterToFilter);
setPayload.filters = filters.map(
(filter: ExternalDashboardFilterWithId) => {
const filterId = existingFilterIds.has(filter.id)
? filter.id
: new ObjectId().toString();
return translateExternalFilterToFilter({ ...filter, id: filterId });
},
);
}
const updatedDashboard = await Dashboard.findOneAndUpdate(
@ -1152,7 +1507,7 @@ router.put(
}
res.json({
data: translateDashboardDocumentToExternalDashboard(updatedDashboard),
data: convertToExternalDashboard(updatedDashboard),
});
} catch (e) {
next(e);

View file

@ -0,0 +1,291 @@
import {
AggregateFunctionSchema,
DisplayType,
SavedChartConfig,
} from '@hyperdx/common-utils/dist/types';
import { pick } from 'lodash';
import _ from 'lodash';
import { DashboardDocument } from '@/models/dashboard';
import { translateFilterToExternalFilter } from '@/utils/externalApi';
import logger from '@/utils/logger';
import {
ExternalDashboardFilterWithId,
ExternalDashboardSelectItem,
ExternalDashboardTileConfig,
ExternalDashboardTileWithId,
externalQuantileLevelSchema,
} from '@/utils/zod';
// --------------------------------------------------------------------------------
// Type Guards and Utility Types
// --------------------------------------------------------------------------------
export type SeriesTile = ExternalDashboardTileWithId & {
series: Exclude<ExternalDashboardTileWithId['series'], undefined>;
};
export function isSeriesTile(
tile: ExternalDashboardTileWithId,
): tile is SeriesTile {
return 'series' in tile && tile.series !== undefined;
}
export type ConfigTile = ExternalDashboardTileWithId & {
config: Exclude<ExternalDashboardTileWithId['config'], undefined>;
};
export function isConfigTile(
tile: ExternalDashboardTileWithId,
): tile is ConfigTile {
return 'config' in tile && tile.config != undefined;
}
export type ExternalDashboard = {
id: string;
name: string;
tiles: ExternalDashboardTileWithId[];
tags?: string[];
filters?: ExternalDashboardFilterWithId[];
};
// --------------------------------------------------------------------------------
// Conversion functions from internal dashboard format to external dashboard format
// --------------------------------------------------------------------------------
const DEFAULT_SELECT_ITEM: ExternalDashboardSelectItem = {
aggFn: 'count',
where: '',
};
const convertToExternalSelectItem = (
item: Exclude<SavedChartConfig['select'][number], string>,
): ExternalDashboardSelectItem => {
const parsedAggFn = AggregateFunctionSchema.safeParse(item.aggFn);
const aggFn = parsedAggFn.success ? parsedAggFn.data : 'none';
const parsedLevel =
'level' in item
? externalQuantileLevelSchema.safeParse(item.level)
: undefined;
const level = parsedLevel?.success ? parsedLevel.data : undefined;
return {
...pick(item, ['valueExpression', 'alias', 'metricType', 'metricName']),
aggFn,
where: item.aggCondition ?? '',
whereLanguage: item.aggConditionLanguage ?? 'lucene',
periodAggFn: item.isDelta ? 'delta' : undefined,
...(level ? { level } : {}),
};
};
const convertToExternalTileChartConfig = (
config: SavedChartConfig,
): ExternalDashboardTileConfig | undefined => {
const sourceId = config.source?.toString() ?? '';
const stringValueOrDefault = <D>(
value: string | unknown,
defaultValue: D,
): string | D => {
return typeof value === 'string' ? value : defaultValue;
};
switch (config.displayType) {
case 'line':
case 'stacked_bar':
return {
displayType: config.displayType,
sourceId,
asRatio:
config.seriesReturnType === 'ratio' &&
Array.isArray(config.select) &&
config.select.length == 2,
alignDateRangeToGranularity: config.alignDateRangeToGranularity,
fillNulls: config.fillNulls !== false,
groupBy: stringValueOrDefault(config.groupBy, undefined),
select: Array.isArray(config.select)
? config.select.map(convertToExternalSelectItem)
: [DEFAULT_SELECT_ITEM],
...(config.displayType === 'line'
? { compareToPreviousPeriod: config.compareToPreviousPeriod }
: {}),
numberFormat: config.numberFormat,
};
case 'number':
return {
displayType: config.displayType,
sourceId,
select: Array.isArray(config.select)
? [convertToExternalSelectItem(config.select[0])]
: [DEFAULT_SELECT_ITEM],
numberFormat: config.numberFormat,
};
case 'table':
return {
...pick(config, ['having', 'numberFormat']),
displayType: config.displayType,
sourceId,
asRatio:
config.seriesReturnType === 'ratio' &&
Array.isArray(config.select) &&
config.select.length == 2,
groupBy: stringValueOrDefault(config.groupBy, undefined),
select: Array.isArray(config.select)
? config.select.map(convertToExternalSelectItem)
: [DEFAULT_SELECT_ITEM],
orderBy: stringValueOrDefault(config.orderBy, undefined),
};
case 'search':
return {
displayType: config.displayType,
sourceId,
select: stringValueOrDefault(config.select, ''),
where: config.where,
whereLanguage: config.whereLanguage ?? 'lucene',
};
case 'markdown':
return {
displayType: config.displayType,
markdown: stringValueOrDefault(config.markdown, ''),
};
default:
logger.error(
{ config },
'Error converting chart config to external chart - unrecognized display type',
);
return undefined;
}
};
function convertTileToExternalChart(
tile: DashboardDocument['tiles'][number],
): ExternalDashboardTileWithId {
// Returned in case of a failure converting the saved chart config
const defaultTileConfig: ExternalDashboardTileConfig = {
displayType: 'line',
sourceId: tile.config.source?.toString() ?? '',
select: [DEFAULT_SELECT_ITEM],
};
return {
...pick(tile, ['id', 'x', 'y', 'w', 'h']),
name: tile.config.name ?? '',
config: convertToExternalTileChartConfig(tile.config) ?? defaultTileConfig,
};
}
export function convertToExternalDashboard(
dashboard: DashboardDocument,
): ExternalDashboard {
return {
id: dashboard._id.toString(),
name: dashboard.name,
tiles: dashboard.tiles.map(convertTileToExternalChart),
tags: dashboard.tags || [],
filters: dashboard.filters?.map(translateFilterToExternalFilter) || [],
};
}
// --------------------------------------------------------------------------------
// Conversion functions from external dashboard format to internal dashboard format
// --------------------------------------------------------------------------------
const convertToInternalSelectItem = (
item: ExternalDashboardSelectItem,
): Exclude<SavedChartConfig['select'][number], string> => {
return {
...pick(item, ['alias', 'metricType', 'metricName', 'aggFn', 'level']),
aggCondition: item.where,
aggConditionLanguage: item.whereLanguage,
isDelta: item.periodAggFn === 'delta',
valueExpression: item.valueExpression ?? '',
};
};
export function convertToInternalTileConfig(
externalTile: ConfigTile,
): DashboardDocument['tiles'][number] {
const externalConfig = externalTile.config;
const name = externalTile.name || '';
let internalConfig: SavedChartConfig;
switch (externalConfig.displayType) {
case 'line':
case 'stacked_bar':
internalConfig = {
...pick(externalConfig, [
'groupBy',
'numberFormat',
'alignDateRangeToGranularity',
'compareToPreviousPeriod',
]),
displayType:
externalConfig.displayType === 'stacked_bar'
? DisplayType.StackedBar
: DisplayType.Line,
select: externalConfig.select.map(convertToInternalSelectItem),
source: externalConfig.sourceId,
where: '',
fillNulls: externalConfig.fillNulls === false ? false : undefined,
seriesReturnType: externalConfig.asRatio ? 'ratio' : undefined,
name,
};
break;
case 'table':
internalConfig = {
...pick(externalConfig, [
'groupBy',
'numberFormat',
'having',
'orderBy',
]),
displayType: DisplayType.Table,
select: externalConfig.select.map(convertToInternalSelectItem),
source: externalConfig.sourceId,
where: '',
seriesReturnType: externalConfig.asRatio ? 'ratio' : undefined,
name,
};
break;
case 'number':
internalConfig = {
displayType: DisplayType.Number,
select: [convertToInternalSelectItem(externalConfig.select[0])],
source: externalConfig.sourceId,
where: '',
numberFormat: externalConfig.numberFormat,
name,
};
break;
case 'search':
internalConfig = {
...pick(externalConfig, ['select', 'where']),
displayType: DisplayType.Search,
source: externalConfig.sourceId,
name,
whereLanguage: externalConfig.whereLanguage ?? 'lucene',
};
break;
case 'markdown':
internalConfig = {
displayType: DisplayType.Markdown,
markdown: externalConfig.markdown,
source: '',
where: '',
select: [],
name,
};
break;
}
// Omit keys that are null/undefined, so that they're not saved as null in Mongo.
// We know that the resulting object will conform to SavedChartConfig since we're just
// removing null properties and anything that is null will just be undefined instead.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const strippedConfig = _.omitBy(internalConfig, _.isNil) as SavedChartConfig;
return {
...pick(externalTile, ['id', 'x', 'y', 'w', 'h', 'name']),
config: strippedConfig,
};
}

View file

@ -6,7 +6,7 @@ import {
SavedChartConfig,
SelectList,
} from '@hyperdx/common-utils/dist/types';
import { omit, pick } from 'lodash';
import { omit } from 'lodash';
import { FlattenMaps, LeanDocument } from 'mongoose';
import {
@ -17,11 +17,10 @@ import {
AlertThresholdType,
} from '@/models/alert';
import type { DashboardDocument } from '@/models/dashboard';
import { SeriesTile } from '@/routers/external-api/v2/utils/dashboards';
import {
ChartSeries,
ExternalDashboardFilter,
ExternalDashboardFilterWithId,
ExternalDashboardTileWithId,
MarkdownChartSeries,
NumberChartSeries,
SearchChartSeries,
@ -204,20 +203,8 @@ const convertChartConfigToExternalChartSeries = (
}
};
function translateTileToExternalChart(
tile: DashboardDocument['tiles'][number],
): ExternalDashboardTileWithId {
const { name, seriesReturnType } = tile.config;
return {
...pick(tile, ['id', 'x', 'y', 'w', 'h']),
asRatio: seriesReturnType === 'ratio',
name: name ?? '',
series: convertChartConfigToExternalChartSeries(tile.config),
};
}
export function translateExternalChartToTileConfig(
chart: ExternalDashboardTileWithId,
chart: SeriesTile,
): DashboardDocument['tiles'][number] {
const { id, name, x, y, w, h, series, asRatio } = chart;
@ -391,21 +378,6 @@ export function translateExternalChartToTileConfig(
};
}
export type ExternalDashboard = {
id: string;
name: string;
tiles: ExternalDashboardTileWithId[];
tags?: string[];
filters?: ExternalDashboardFilterWithId[];
};
export type ExternalDashboardRequest = {
name: string;
tiles: ExternalDashboardTileWithId[];
tags?: string[];
filters?: ExternalDashboardFilter[];
};
export function translateFilterToExternalFilter(
filter: DashboardFilter,
): ExternalDashboardFilterWithId {
@ -424,18 +396,6 @@ export function translateExternalFilterToFilter(
};
}
export function translateDashboardDocumentToExternalDashboard(
dashboard: DashboardDocument,
): ExternalDashboard {
return {
id: dashboard._id.toString(),
name: dashboard.name,
tiles: dashboard.tiles.map(translateTileToExternalChart),
tags: dashboard.tags || [],
filters: dashboard.filters?.map(translateFilterToExternalFilter) || [],
};
}
// Alert related types and transformations
export type ExternalAlert = {
id: string;

View file

@ -22,9 +22,9 @@ export const sourceTableSchema = z.union([
export type SourceTable = z.infer<typeof sourceTableSchema>;
// ==============================
// Charts & Dashboards
// ==============================
// ================================
// Charts & Dashboards (old format)
// ================================
const percentileLevelSchema = z.number().min(0).max(1).optional();
@ -123,39 +123,6 @@ const chartSeriesSchema = z.discriminatedUnion('type', [
export type ChartSeries = z.infer<typeof chartSeriesSchema>;
export const externalDashboardTileSchema = z.object({
name: z.string(),
x: z.number().min(0).max(23),
y: z.number().min(0),
w: z.number().min(1).max(24),
h: z.number().min(1),
series: chartSeriesSchema
.array()
.min(1)
.superRefine((series, ctx) => {
const types = series.map(s => s.type);
if (!types.every(t => t === types[0])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'All series must have the same type',
});
}
}),
asRatio: z.boolean().optional(),
});
export const externalDashboardTileSchemaWithId =
externalDashboardTileSchema.and(
z.object({
// User defined ID
id: z.string().max(36),
}),
);
export type ExternalDashboardTileWithId = z.infer<
typeof externalDashboardTileSchemaWithId
>;
export const tagsSchema = z.array(z.string().max(32)).max(50).optional();
export const externalDashboardFilterSchemaWithId = DashboardFilterSchema.omit({
@ -175,6 +142,229 @@ export type ExternalDashboardFilter = z.infer<
typeof externalDashboardFilterSchema
>;
// ================================
// Dashboards (new format)
// ================================
export const externalQuantileLevelSchema = z.union([
z.literal(0.5),
z.literal(0.9),
z.literal(0.95),
z.literal(0.99),
]);
const externalDashboardSelectItemSchema = z
.object({
// For logs, traces, and metrics
valueExpression: z.string().max(10000).optional(),
alias: z.string().max(10000).optional(),
aggFn: AggregateFunctionSchema,
level: externalQuantileLevelSchema.optional(),
where: z.string().max(10000).optional().default(''),
whereLanguage: whereLanguageSchema.optional(),
// For metrics only
metricType: z.nativeEnum(MetricsDataType).optional(),
metricName: z.string().optional(),
periodAggFn: z.enum(['delta']).optional(),
})
.superRefine((data, ctx) => {
if (data.level && data.aggFn !== 'quantile') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Level can only be used with quantile aggregation function',
});
}
if (data.valueExpression && data.aggFn === 'count') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Value expression cannot be used with count aggregation function',
});
} else if (!data.valueExpression && data.aggFn !== 'count') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Value expression is required for non-count aggregation functions',
});
}
});
export type ExternalDashboardSelectItem = z.infer<
typeof externalDashboardSelectItemSchema
>;
const externalDashboardTimeChartConfigSchema = z.object({
sourceId: objectIdSchema,
select: z.array(externalDashboardSelectItemSchema).min(1).max(20),
groupBy: z.string().max(10000).optional(),
asRatio: z.boolean().optional(),
alignDateRangeToGranularity: z.boolean().optional(),
fillNulls: z.boolean().optional(),
numberFormat: NumberFormatSchema.optional(),
});
const externalDashboardLineChartConfigSchema =
externalDashboardTimeChartConfigSchema.extend({
displayType: z.literal('line'),
compareToPreviousPeriod: z.boolean().optional(),
});
const externalDashboardBarChartConfigSchema =
externalDashboardTimeChartConfigSchema.extend({
displayType: z.literal('stacked_bar'),
});
const externalDashboardTableChartConfigSchema = z.object({
displayType: z.literal('table'),
sourceId: objectIdSchema,
select: z.array(externalDashboardSelectItemSchema).min(1).max(20),
groupBy: z.string().max(10000).optional(),
having: z.string().max(10000).optional(),
orderBy: z.string().max(10000).optional(),
asRatio: z.boolean().optional(),
numberFormat: NumberFormatSchema.optional(),
});
const externalDashboardNumberChartConfigSchema = z.object({
displayType: z.literal('number'),
sourceId: objectIdSchema,
select: z.array(externalDashboardSelectItemSchema).length(1),
numberFormat: NumberFormatSchema.optional(),
});
const externalDashboardSearchChartConfigSchema = z.object({
displayType: z.literal('search'),
sourceId: objectIdSchema,
select: z.string().max(10000),
where: z.string().max(10000).optional().default(''),
whereLanguage: whereLanguageSchema,
});
const externalDashboardMarkdownChartConfigSchema = z.object({
displayType: z.literal('markdown'),
markdown: z.string().max(50000).optional(),
});
export const externalDashboardTileConfigSchema = z
.discriminatedUnion('displayType', [
externalDashboardLineChartConfigSchema,
externalDashboardBarChartConfigSchema,
externalDashboardTableChartConfigSchema,
externalDashboardNumberChartConfigSchema,
externalDashboardMarkdownChartConfigSchema,
externalDashboardSearchChartConfigSchema,
])
.superRefine((data, ctx) => {
if (
'asRatio' in data &&
data.asRatio &&
(!Array.isArray(data.select) || data.select.length !== 2)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'asRatio can only be used with exactly two select items',
});
}
});
export type ExternalDashboardTileConfig = z.infer<
typeof externalDashboardTileConfigSchema
>;
// ================================
// Dashboards (Old + New formats)
// ================================
export const externalDashboardTileSchema = z
.object({
name: z.string(),
x: z.number().min(0).max(23),
y: z.number().min(0),
w: z.number().min(1).max(24),
h: z.number().min(1),
asRatio: z.boolean().optional(),
series: chartSeriesSchema
.array()
.min(1)
.superRefine((series, ctx) => {
const types = series.map(s => s.type);
if (!types.every(t => t === types[0])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'All series must have the same type',
});
}
})
.optional(),
config: externalDashboardTileConfigSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.series && data.config) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Tile cannot have both series and config',
});
} else if (!data.series && !data.config) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Tile must have either series or config',
});
}
if (data.asRatio != undefined && data.config) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'asRatio property is not supported when using config property. Specify config.asRatio instead.',
});
}
});
export type ExternalDashboardTile = z.infer<typeof externalDashboardTileSchema>;
export const externalDashboardTileSchemaWithOptionalId =
externalDashboardTileSchema.and(
z.object({
// User defined ID
id: z.string().max(36).optional(),
}),
);
export type ExternalDashboardTileWithOptionalId = z.infer<
typeof externalDashboardTileSchemaWithOptionalId
>;
export const externalDashboardTileSchemaWithId =
externalDashboardTileSchema.and(
z.object({
// User defined ID
id: z.string().max(36),
}),
);
export type ExternalDashboardTileWithId = z.infer<
typeof externalDashboardTileSchemaWithId
>;
export const externalDashboardTileListSchema = z
.array(externalDashboardTileSchemaWithOptionalId)
.superRefine((tiles, ctx) => {
const seen = new Set<string>();
for (const tile of tiles) {
if (tile.id && seen.has(tile.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate tile ID: ${tile.id}. Omit the ID to generate a unique one.`,
});
}
if (tile.id) {
seen.add(tile.id);
}
}
});
// ==============================
// Alerts
// ==============================

View file

@ -0,0 +1,390 @@
/**
* External API E2E Tests for Dashboard Creation (Config Format)
*
* Tests the external API endpoint for creating dashboards via REST API
* using the "config" format instead of the deprecated "series" format.
* See dashboard-external-api.spec.ts for the equivalent tests using the
* legacy "series" format.
*/
import { DashboardPage, TileConfig } from '../page-objects/DashboardPage';
import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
test.describe(
'Dashboard External API (Config Format)',
{ tag: ['@full-stack', '@api'] },
() => {
const API_URL = getApiUrl();
const BASE_URL = `${API_URL}/api/v2/dashboards`;
let accessKey: string;
let sourceId: string;
let sourceName: string;
// Helper function to generate unique dashboard names
const generateUniqueDashboardName = (baseName: string) => {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${baseName} ${timestamp}-${random}`;
};
test.beforeEach(async ({ page }) => {
// Get the current user's access key for API authentication
accessKey = await getUserAccessKey(page);
// Get available log sources to use in dashboard tiles
const logSources = await getSources(page, 'log');
expect(logSources.length).toBeGreaterThan(0);
// Use the first available log source for our test dashboard
const selectedSource = logSources[0];
sourceId = selectedSource._id;
sourceName = selectedSource.name;
});
test('should create a dashboard with multiple chart types via external API', async ({
page,
}) => {
const dashboardPayload = {
name: generateUniqueDashboardName('Dashboard'),
tiles: [
{
name: 'Time Series Chart',
x: 0,
y: 0,
w: 12,
h: 4,
config: {
displayType: 'line',
sourceId: sourceId,
select: [
{
aggFn: 'count',
where: "SeverityText = 'error'",
whereLanguage: 'sql',
alias: 'Error Count',
},
{
aggFn: 'max',
valueExpression: 'length(ServiceName)',
where: "SeverityText = 'error'",
whereLanguage: 'sql',
alias: 'Max Service Name Length',
},
],
},
},
{
name: 'Number Chart',
x: 12,
y: 0,
w: 12,
h: 4,
config: {
displayType: 'number',
sourceId: sourceId,
select: [
{
aggFn: 'max',
valueExpression: 'length(ServiceName)',
where: 'ServiceName:*',
whereLanguage: 'lucene',
alias: 'Max Service Name Length',
},
],
},
},
{
name: 'Table Chart',
x: 0,
y: 4,
w: 12,
h: 4,
config: {
displayType: 'table',
sourceId: sourceId,
select: [
{
aggFn: 'count',
where: 'SeverityText:*',
alias: 'Non-Debug Events by Service',
},
],
groupBy: 'ServiceName',
},
},
{
name: 'Search Chart',
x: 12,
y: 4,
w: 12,
h: 4,
config: {
displayType: 'search',
sourceId: sourceId,
select: 'ServiceName, Body, SeverityText',
where: 'SeverityText:"info"',
whereLanguage: 'lucene',
},
},
{
name: 'Markdown Widget',
x: 0,
y: 8,
w: 12,
h: 4,
config: {
displayType: 'markdown',
markdown:
'# Dashboard Info\n\nThis dashboard was created via the external API.',
},
},
],
tags: ['e2e-test'],
};
let dashboardPage: DashboardPage;
let tiles: any;
await test.step('Create dashboard via external API', async () => {
const createResponse = await page.request.post(BASE_URL, {
headers: {
Authorization: `Bearer ${accessKey}`,
'Content-Type': 'application/json',
},
data: dashboardPayload,
});
expect(createResponse.ok()).toBeTruthy();
});
await test.step('Navigate to dashboard and verify tiles', async () => {
dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
// Find and click on the created dashboard
await dashboardPage.goToDashboardByName(dashboardPayload.name);
await expect(
page.getByRole('heading', { name: dashboardPayload.name }),
).toBeVisible({ timeout: 10000 });
// Verify all tiles are rendered
tiles = page.locator('[data-testid^="dashboard-tile-"]');
await expect(tiles).toHaveCount(5, { timeout: 10000 });
});
await test.step('Verify each tile configuration', async () => {
for (let i = 0; i < dashboardPayload.tiles.length; i++) {
const tileData = dashboardPayload.tiles[i];
// Hover over tile to reveal edit button
await tiles.nth(i).hover();
// Click edit button for this tile
const editButton = page
.locator(`[data-testid^="tile-edit-button-"]`)
.nth(i);
await expect(editButton).toBeVisible();
await editButton.click();
// Wait for chart editor modal to open
const chartNameInput = page.getByTestId('chart-name-input');
await expect(chartNameInput).toBeVisible({ timeout: 5000 });
// Verify chart name matches
await expect(chartNameInput).toHaveValue(tileData.name);
// Verify tile edit form
await dashboardPage.verifyTileFormFromConfig(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tileData.config as TileConfig,
sourceName,
);
// Close the modal by pressing Escape
await page.keyboard.press('Escape');
// Wait for modal to close
await expect(chartNameInput).toBeHidden({ timeout: 5000 });
}
});
// Test that we can update the dashboard, proving that the dashboard can be saved after creation in the UI
// (indicating that the saved value of the dashboard conforms to the expected schema of the internal dashboard API)
await test.step('Duplicate a tile via UI', async () => {
// Duplicate the first tile
await dashboardPage.duplicateTile(0);
// Verify the duplicated tile was added (should now have 6 tiles)
await expect(tiles).toHaveCount(6, { timeout: 10000 });
});
});
test('should update dashboard via external API', async ({ page }) => {
// Create a dashboard
const initialPayload = {
name: generateUniqueDashboardName('Update Test Dashboard'),
tiles: [
{
name: 'Original Chart',
x: 0,
y: 0,
w: 6,
h: 3,
config: {
displayType: 'line',
sourceId: sourceId,
select: [
{
aggFn: 'count',
where: '',
},
],
},
},
],
tags: ['update-test'],
};
const createResponse = await page.request.post(BASE_URL, {
headers: {
Authorization: `Bearer ${accessKey}`,
'Content-Type': 'application/json',
},
data: initialPayload,
});
expect(createResponse.ok()).toBeTruthy();
const createdDashboard = (await createResponse.json()).data;
const dashboardId = createdDashboard.id;
const tileId = createdDashboard.tiles[0].id;
const originalName = createdDashboard.name;
const updatedName = originalName + ' Updated';
// Update the dashboard
const updatedPayload = {
name: updatedName,
tiles: [
{
id: tileId,
name: 'Updated Chart',
x: 0,
y: 0,
w: 12,
h: 4,
config: {
displayType: 'line',
sourceId: sourceId,
select: [
{
aggFn: 'sum',
valueExpression: 'Duration',
where: '',
},
],
groupBy: 'ServiceName',
},
},
],
tags: ['update-test'],
};
const updateResponse = await page.request.put(
`${BASE_URL}/${dashboardId}`,
{
headers: {
Authorization: `Bearer ${accessKey}`,
'Content-Type': 'application/json',
},
data: updatedPayload,
},
);
expect(updateResponse.ok()).toBeTruthy();
// Navigate to dashboard through UI (via AppNav)
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
// Find and click on the updated dashboard
await dashboardPage.goToDashboardByName(updatedName);
await expect(
page.getByRole('heading', { name: updatedName }),
).toBeVisible({ timeout: 10000 });
});
test('should delete dashboard via external API', async ({ page }) => {
// Create two dashboards - one to delete, one to keep
const dashboardToDelete = {
name: generateUniqueDashboardName('Delete Test Dashboard'),
tiles: [],
tags: ['delete-test'],
};
const dashboardToKeep = {
name: generateUniqueDashboardName('Keep Test Dashboard'),
tiles: [],
tags: ['delete-test'],
};
const createResponse1 = await page.request.post(BASE_URL, {
headers: {
Authorization: `Bearer ${accessKey}`,
'Content-Type': 'application/json',
},
data: dashboardToDelete,
});
expect(createResponse1.ok()).toBeTruthy();
const dashboardId = (await createResponse1.json()).data.id;
const createResponse2 = await page.request.post(BASE_URL, {
headers: {
Authorization: `Bearer ${accessKey}`,
'Content-Type': 'application/json',
},
data: dashboardToKeep,
});
expect(createResponse2.ok()).toBeTruthy();
// Delete the first dashboard
const deleteResponse = await page.request.delete(
`${BASE_URL}/${dashboardId}`,
{
headers: {
Authorization: `Bearer ${accessKey}`,
},
},
);
expect(deleteResponse.ok()).toBeTruthy();
// Verify dashboard is deleted via API
const getResponse = await page.request.get(`${BASE_URL}/${dashboardId}`, {
headers: {
Authorization: `Bearer ${accessKey}`,
},
});
expect(getResponse.status()).toBe(404);
// Verify dashboard is not present in UI
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
// First verify the kept dashboard is visible (ensures data has loaded)
const keptDashboardLink = page.locator(`text="${dashboardToKeep.name}"`);
await expect(keptDashboardLink).toBeVisible({ timeout: 10000 });
// Then verify the deleted dashboard is not visible in the list
const deletedDashboardLink = page.locator(
`text="${dashboardToDelete.name}"`,
);
await expect(deletedDashboardLink).toHaveCount(0);
});
},
);

View file

@ -1,5 +1,5 @@
/**
* External API E2E Tests for Dashboard Creation
* External API E2E Tests for Dashboard Creation (Deprecated "series" format)
*
* Tests the external API endpoint for creating dashboards via REST API
* instead of through the UI. This validates the external API integration
@ -10,7 +10,7 @@ import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
test.describe(
'Dashboard External API',
'Dashboard External API (Series Format)',
{ tag: ['@full-stack', '@api'] },
() => {
const API_URL = getApiUrl();

View file

@ -2,12 +2,35 @@
* DashboardPage - Page object for dashboard pages
* Encapsulates interactions with dashboard creation, editing, and tile management
*/
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import { expect, Locator, Page } from '@playwright/test';
import { ChartEditorComponent } from '../components/ChartEditorComponent';
import { TimePickerComponent } from '../components/TimePickerComponent';
import { getSqlEditor } from '../utils/locators';
/**
* Config format tile config, as accepted by the external dashboard API.
* Used with verifyTileFormFromConfig
*/
export type TileConfig = {
displayType: Exclude<DisplayType, 'heatmap'>;
sourceId?: string;
select?:
| {
aggFn?: string;
where?: string;
whereLanguage?: 'sql' | 'lucene';
alias?: string;
valueExpression?: string;
}[]
| string;
where?: string;
whereLanguage?: 'sql' | 'lucene';
groupBy?: string;
markdown?: string;
};
/**
* Series data structure for chart verification
* Supports all chart types: time, number, table, search, markdown
@ -393,6 +416,59 @@ export class DashboardPage {
return this.page.getByRole('tab', { name: new RegExp(type, 'i') });
}
/**
* Convert a config-format tile config to SeriesData for form verification.
*/
private configToSeriesData(config: TileConfig): SeriesData[] {
if (config.displayType === 'markdown') {
return [{ type: 'markdown', content: config.markdown }];
}
if (config.displayType === 'search') {
return [
{
type: 'search',
sourceId: config.sourceId,
where: config.where,
whereLanguage: config.whereLanguage ?? 'lucene',
},
];
}
const type: SeriesData['type'] =
config.displayType === 'line' || config.displayType === 'stacked_bar'
? 'time'
: config.displayType;
const groupBy = config.groupBy ? [config.groupBy] : undefined;
const selectItems = Array.isArray(config.select) ? config.select : [];
return selectItems.map(item => ({
type,
sourceId: config.sourceId,
aggFn: item.aggFn,
where: item.where,
whereLanguage: item.whereLanguage ?? 'lucene',
alias: item.alias,
field: item.valueExpression,
groupBy,
}));
}
/**
* Verify tile edit form using the config-format tile config directly,
* avoiding the need for a separate SeriesData verification array.
*/
async verifyTileFormFromConfig(
config: TileConfig,
expectedSourceName?: string,
) {
await this.verifyTileForm(
this.configToSeriesData(config),
expectedSourceName,
);
}
/**
* Verify tile edit form matches the given series data
* @param series - Array of series data from the API request

View file

@ -74,6 +74,8 @@ export const AggregateFunctionWithCombinatorsSchema = z
.string()
.regex(/^(\w+)If(State|Merge)$/);
// When making changes here, consider if they need to be made to the external API
// schema as well (packages/api/src/utils/zod.ts).
export const RootValueExpressionSchema = z
.object({
aggFn: z.union([
@ -154,6 +156,8 @@ export const ChSqlSchema = z.object({
params: z.record(z.string(), z.any()),
});
// When making changes here, consider if they need to be made to the external API
// schema as well (packages/api/src/utils/zod.ts).
export const SelectSQLStatementSchema = z.object({
select: SelectListSchema,
from: z.object({
@ -408,6 +412,8 @@ export const NumberFormatSchema = z.object({
export type NumberFormat = z.infer<typeof NumberFormatSchema>;
// When making changes here, consider if they need to be made to the external API
// schema as well (packages/api/src/utils/zod.ts).
export const _ChartConfigSchema = z.object({
displayType: z.nativeEnum(DisplayType).optional(),
numberFormat: NumberFormatSchema.optional(),
@ -493,6 +499,8 @@ export type ChartConfigWithOptDateRange = Omit<
timestampValueExpression?: string;
} & Partial<DateRange>;
// When making changes here, consider if they need to be made to the external API
// schema as well (packages/api/src/utils/zod.ts).
export const SavedChartConfigSchema = z
.object({
name: z.string().optional(),