mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add config property to external dashboard APIs. Deprecate series. (#1763)
This commit is contained in:
parent
90a733aab8
commit
b676f268d9
12 changed files with 3807 additions and 505 deletions
6
.changeset/selfish-dancers-greet.md
Normal file
6
.changeset/selfish-dancers-greet.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": minor
|
||||
"@hyperdx/api": minor
|
||||
---
|
||||
|
||||
feat: Add config property to external dashboard APIs. Deprecate series.
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
291
packages/api/src/routers/external-api/v2/utils/dashboards.ts
Normal file
291
packages/api/src/routers/external-api/v2/utils/dashboards.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==============================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue