mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-07 17:00:41 +00:00
Compare commits
30 Commits
fix/genera
...
feat/geo-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a27b3be3b1 | ||
|
|
7380272854 | ||
|
|
2c76fd5051 | ||
|
|
401197f0e5 | ||
|
|
54fd55e20e | ||
|
|
947e36a62c | ||
|
|
7ce8285091 | ||
|
|
e06b07788f | ||
|
|
8e63624de8 | ||
|
|
4c815e0224 | ||
|
|
223b64e5af | ||
|
|
10f4364abc | ||
|
|
3aa53d67e3 | ||
|
|
8f87ed3ed4 | ||
|
|
1161c249f2 | ||
|
|
3778d23a41 | ||
|
|
b9104596ca | ||
|
|
e9ab776ced | ||
|
|
eaa922cabe | ||
|
|
f64bc495cc | ||
|
|
6a42b9af90 | ||
|
|
517cba0bee | ||
|
|
118ccb8f12 | ||
|
|
3bd710deb7 | ||
|
|
44e31d7dd8 | ||
|
|
5d411a8221 | ||
|
|
effb7df6fa | ||
|
|
12a0c3fd64 | ||
|
|
d34b7ee637 | ||
|
|
a802e1422e |
@@ -4280,6 +4280,11 @@ components:
|
||||
last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a
|
||||
fixed window.'
|
||||
type: object
|
||||
DashboardtypesBubbleRadius:
|
||||
maximum: 64
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesBuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -4512,6 +4517,231 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGeoMapBubbles:
|
||||
properties:
|
||||
maxRadius:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
description: Radius of the largest circle, in CSS pixels from 1 to 64. Null
|
||||
means 8 on the points layer, 28 otherwise.
|
||||
minRadius:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
description: Radius of the smallest circle, in CSS pixels from 1 to 64.
|
||||
Null means 2 on the points layer, 3 otherwise.
|
||||
type: object
|
||||
DashboardtypesGeoMapChartAppearance:
|
||||
properties:
|
||||
bubbles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
|
||||
description: The circle radius range of the bubbles and points layers.
|
||||
colors:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapColors'
|
||||
description: The color scale applied to the colorBy values.
|
||||
labels:
|
||||
description: Shows country and state names once each fits inside its shape.
|
||||
Null shows them.
|
||||
nullable: true
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesGeoMapColors:
|
||||
properties:
|
||||
fill:
|
||||
description: The hex color varied in opacity mode. Empty uses the default
|
||||
fill.
|
||||
type: string
|
||||
max:
|
||||
description: The value mapped to the highest color. Null uses the highest
|
||||
value in the data.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: The value mapped to the lowest color. Null uses the lowest
|
||||
value in the data. Must not exceed max.
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
description: 'How values are colored: palette (default) picks a hue from
|
||||
palette, opacity varies the opacity of fill.'
|
||||
palette:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
description: The named color ramp used in palette mode, such as lava (default),
|
||||
ice or ember.
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
description: 'How values map onto the color range: log (default), sqrt or
|
||||
linear.'
|
||||
steps:
|
||||
description: The number of distinct color steps, 2 to 128. 0 uses the chart
|
||||
default.
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesGeoMapCountryFormat:
|
||||
enum:
|
||||
- auto
|
||||
- alpha2
|
||||
- alpha3
|
||||
- name
|
||||
type: string
|
||||
DashboardtypesGeoMapLayer:
|
||||
enum:
|
||||
- regions
|
||||
- bubbles
|
||||
- points
|
||||
type: string
|
||||
DashboardtypesGeoMapLocation:
|
||||
properties:
|
||||
country:
|
||||
description: Group-by key holding the country, for the country and subdivision
|
||||
modes. Empty detects it by name, such as geo.country.iso_code.
|
||||
type: string
|
||||
countryFormat:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
|
||||
description: 'How country values are written: auto (default) detects it,
|
||||
alpha2, alpha3 or name.'
|
||||
lat:
|
||||
description: Group-by key holding the latitude, for the coordinates mode.
|
||||
Empty detects it by name.
|
||||
type: string
|
||||
lon:
|
||||
description: Group-by key holding the longitude, for the coordinates mode.
|
||||
Empty detects it by name.
|
||||
type: string
|
||||
mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
|
||||
description: 'How rows are placed: auto (default) detects it from the group-by
|
||||
keys, country, subdivision or coordinates.'
|
||||
region:
|
||||
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
|
||||
for the subdivision mode. Empty detects it by name.
|
||||
type: string
|
||||
required:
|
||||
- mode
|
||||
- countryFormat
|
||||
type: object
|
||||
DashboardtypesGeoMapLocationMode:
|
||||
enum:
|
||||
- auto
|
||||
- country
|
||||
- subdivision
|
||||
- coordinates
|
||||
type: string
|
||||
DashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
|
||||
description: Color scale, bubble size and labels.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
description: Per-column units and decimal precision.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement.
|
||||
location:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLocation'
|
||||
description: The group-by columns that place each row on the map.
|
||||
map:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapSettings'
|
||||
description: Layer, view, projection and the value columns drawn.
|
||||
thresholds:
|
||||
description: Rules that recolor a shape, bubble or point when its value
|
||||
compares to a threshold as specified.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the values are computed over.
|
||||
type: object
|
||||
DashboardtypesGeoMapProjection:
|
||||
enum:
|
||||
- mercator
|
||||
- equal_earth
|
||||
- natural_earth
|
||||
type: string
|
||||
DashboardtypesGeoMapSettings:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Value column key, as in a Table panel, that sets the color.
|
||||
Empty uses the first value column.
|
||||
type: string
|
||||
layer:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLayer'
|
||||
description: 'What is drawn: regions (default) fills each shape, bubbles
|
||||
draws a circle per region, points a circle per coordinate.'
|
||||
projection:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapProjection'
|
||||
description: 'The map projection: mercator (default), equal_earth or natural_earth.'
|
||||
sizeBy:
|
||||
description: Value column key, as in a Table panel, that sets the bubble
|
||||
or point size. Empty uses colorBy.
|
||||
type: string
|
||||
view:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapView'
|
||||
description: 'The area shown: world (default), fit to the data, a continent,
|
||||
or country with viewCountry.'
|
||||
viewCountry:
|
||||
description: Uppercase ISO 3166-1 alpha-2 code of the country the country
|
||||
view shows.
|
||||
type: string
|
||||
required:
|
||||
- layer
|
||||
- view
|
||||
- projection
|
||||
type: object
|
||||
DashboardtypesGeoMapThreshold:
|
||||
properties:
|
||||
color:
|
||||
description: 'The CSS color of the shape, bubble or point while the threshold
|
||||
is active, for example #F1575F; CSS named colors such as red also work.'
|
||||
type: string
|
||||
operator:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesComparisonOperator'
|
||||
description: 'The comparison that activates the threshold: above (default),
|
||||
below, above_or_equal, below_or_equal, equal or not_equal.'
|
||||
unit:
|
||||
description: The unit id of value, such as ms or percent. Empty means the
|
||||
colorBy column's unit.
|
||||
type: string
|
||||
value:
|
||||
description: The value compared against, in unit.
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesGeoMapView:
|
||||
enum:
|
||||
- world
|
||||
- fit
|
||||
- africa
|
||||
- asia
|
||||
- europe
|
||||
- north_america
|
||||
- south_america
|
||||
- oceania
|
||||
- country
|
||||
type: string
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -5249,6 +5479,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/GeoMapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5271,6 +5502,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5285,6 +5517,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5316,6 +5549,21 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
|
||||
@@ -2158,7 +2158,7 @@ func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "k8s.pod.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B"}`,
|
||||
expectedErrorSubstring: "queries must group by the same keys so their rows join into dots; \"A\" groups by [`service.name`], \"B\" groups by [`k8s.pod.name`]",
|
||||
expectedErrorSubstring: "queries must group by the same keys so their rows join; \"A\" groups by [`service.name`], \"B\" groups by [`k8s.pod.name`]",
|
||||
},
|
||||
{
|
||||
name: "Queries_SameGroupByDifferentOrder_Accepted",
|
||||
@@ -2245,6 +2245,174 @@ func TestNumberPanelDefaults(t *testing.T) {
|
||||
assert.Contains(t, outputStr, `"operator":"above"`, "expected stored/response JSON to contain operator:above")
|
||||
}
|
||||
|
||||
func geoMapDashboard(spec, queryPlugin string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/GeoMapPanel", "spec": ` + spec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
func TestGeoMapPanelSpec(t *testing.T) {
|
||||
data := geoMapDashboard(`{
|
||||
"formatting": {"columnUnits": {"A": "percent"}, "decimalPrecision": "2"},
|
||||
"location": {"mode": "subdivision", "country": "geo.country.iso_code", "countryFormat": "alpha2", "region": "geo.region.iso_code"},
|
||||
"map": {"layer": "bubbles", "view": "country", "viewCountry": "FR", "projection": "equal_earth", "colorBy": "A", "sizeBy": "A"},
|
||||
"chartAppearance": {
|
||||
"colors": {"mode": "opacity", "fill": "#4E74F8", "palette": "ice", "scale": "linear", "steps": 16, "min": 0, "max": 10},
|
||||
"bubbles": {"minRadius": 2, "maxRadius": 30},
|
||||
"labels": false
|
||||
},
|
||||
"legend": {"position": "right"},
|
||||
"thresholds": [{"value": 5, "operator": "above", "unit": "percent", "color": "#F24769"}]
|
||||
}`, `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &GeoMapPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*GeoMapPanelSpec)
|
||||
assert.Equal(t, "subdivision", spec.Location.Mode.ValueOrDefault())
|
||||
assert.Equal(t, "country", spec.Map.View.ValueOrDefault())
|
||||
assert.Equal(t, "FR", spec.Map.ViewCountry)
|
||||
assert.Equal(t, "equal_earth", spec.Map.Projection.ValueOrDefault())
|
||||
require.NotNil(t, spec.ChartAppearance.Labels)
|
||||
assert.False(t, *spec.ChartAppearance.Labels)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
roundTripped, err := unmarshalDashboard(output)
|
||||
require.NoError(t, err, "round trip failed")
|
||||
roundTrippedSpec := roundTripped.Panels["p1"].Spec.Plugin.Spec.(*GeoMapPanelSpec)
|
||||
assert.Equal(t, spec.Formatting, roundTrippedSpec.Formatting)
|
||||
assert.Equal(t, spec.Location, roundTrippedSpec.Location)
|
||||
assert.Equal(t, spec.Map, roundTrippedSpec.Map)
|
||||
assert.Equal(t, spec.ChartAppearance, roundTrippedSpec.ChartAppearance)
|
||||
assert.Equal(t, spec.Thresholds, roundTrippedSpec.Thresholds)
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(geoMapDashboard(`{}`, `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 2"}}`))
|
||||
require.NoError(t, err)
|
||||
output, err := json.Marshal(d.Panels["p1"].Spec.Plugin.Spec)
|
||||
require.NoError(t, err)
|
||||
for _, field := range []string{`"mode":"palette"`, `"mode":"auto"`, `"countryFormat":"auto"`, `"layer":"regions"`, `"view":"world"`, `"projection":"mercator"`, `"palette":"lava"`, `"scale":"log"`, `"labels":null`} {
|
||||
assert.Contains(t, string(output), field)
|
||||
}
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "UnknownField_Rejected", spec: `{"unit": "ms"}`, wantErr: true},
|
||||
{name: "ThresholdFormat_Rejected", spec: `{"thresholds": [{"value": 5, "color": "Red", "format": "background"}]}`, wantErr: true},
|
||||
{name: "UnknownLayer_Rejected", spec: `{"map": {"layer": "heat"}}`, wantErr: true},
|
||||
{name: "UnknownView_Rejected", spec: `{"map": {"view": "emea"}}`, wantErr: true},
|
||||
{name: "UnknownProjection_Rejected", spec: `{"map": {"projection": "robinson"}}`, wantErr: true},
|
||||
{name: "UnknownMapField_Rejected", spec: `{"map": {"zoom": 3}}`, wantErr: true},
|
||||
{name: "CountryViewWithoutCountry_Rejected", spec: `{"map": {"view": "country"}}`, wantErr: true},
|
||||
{name: "LowercaseViewCountry_Rejected", spec: `{"map": {"view": "country", "viewCountry": "fr"}}`, wantErr: true},
|
||||
{name: "UnassignedViewCountry_Rejected", spec: `{"map": {"view": "country", "viewCountry": "XX"}}`, wantErr: true},
|
||||
{name: "UnknownLocationMode_Rejected", spec: `{"location": {"mode": "geohash"}}`, wantErr: true},
|
||||
{name: "UnknownCountryFormat_Rejected", spec: `{"location": {"countryFormat": "numeric"}}`, wantErr: true},
|
||||
{name: "ColorMinAboveMax_Rejected", spec: `{"chartAppearance": {"colors": {"min": 10, "max": 1}}}`, wantErr: true},
|
||||
{name: "UnknownColorMode_Rejected", spec: `{"chartAppearance": {"colors": {"mode": "gradient"}}}`, wantErr: true},
|
||||
{name: "UnknownColorsField_Rejected", spec: `{"chartAppearance": {"colors": {"opacity": 0.5}}}`, wantErr: true},
|
||||
{name: "NonHexFill_Rejected", spec: `{"chartAppearance": {"colors": {"fill": "blue"}}}`, wantErr: true},
|
||||
{name: "TooFewSteps_Rejected", spec: `{"chartAppearance": {"colors": {"steps": 1}}}`, wantErr: true},
|
||||
{name: "RadiusOutOfRange_Rejected", spec: `{"chartAppearance": {"bubbles": {"maxRadius": 65}}}`, wantErr: true},
|
||||
{name: "MinRadiusAboveMax_Rejected", spec: `{"chartAppearance": {"bubbles": {"minRadius": 20, "maxRadius": 10}}}`, wantErr: true},
|
||||
{name: "MinRadiusAboveDefaultMax_Rejected", spec: `{"chartAppearance": {"bubbles": {"minRadius": 64}}}`, wantErr: true},
|
||||
{name: "PointsLayer_MinRadiusAboveDefaultMax_Rejected", spec: `{"map": {"layer": "points"}, "chartAppearance": {"bubbles": {"minRadius": 10}}}`, wantErr: true},
|
||||
{name: "BubblesLayer_MinRadiusBelowDefaultMax_Accepted", spec: `{"map": {"layer": "bubbles"}, "chartAppearance": {"bubbles": {"minRadius": 10}}}`},
|
||||
{name: "UnknownBubblesField_Rejected", spec: `{"chartAppearance": {"bubbles": {"radius": 4}}}`, wantErr: true},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(geoMapDashboard(testCase.spec, `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 3"}}`))
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoMapPanelBindsToQuery(t *testing.T) {
|
||||
builderQuery := `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.country.iso_code"}]}}`
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryPlugin string
|
||||
spec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
name: "LocationAndValueColumns_Accepted",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"location": {"mode": "country", "country": "geo.country.iso_code"}, "map": {"colorBy": "A", "sizeBy": "A"}}`,
|
||||
},
|
||||
{
|
||||
name: "LocationKeyNotGroupedBy_Rejected",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"location": {"mode": "coordinates", "lat": "geo.location.lat"}}`,
|
||||
expectedErrorSubstring: "location.lat: \"geo.location.lat\" is not a group-by key of the panel's query; group-by keys: `geo.country.iso_code`",
|
||||
},
|
||||
{
|
||||
name: "ValueColumnMissing_Rejected",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"map": {"sizeBy": "B"}}`,
|
||||
expectedErrorSubstring: "map.sizeBy: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
name: "QueriesGroupedByDifferentKeys_Rejected",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.country.iso_code"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "logs", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.region.iso_code"}]}}
|
||||
]}}`,
|
||||
spec: `{"map": {"colorBy": "A", "sizeBy": "B"}}`,
|
||||
expectedErrorSubstring: "queries must group by the same keys so their rows join; \"A\" groups by [`geo.country.iso_code`], \"B\" groups by [`geo.region.iso_code`]",
|
||||
},
|
||||
{
|
||||
name: "QueriesGroupedBySameKeys_FormulaSkipped_Accepted",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.country.iso_code"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.country.iso_code"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A / B"}}
|
||||
]}}`,
|
||||
spec: `{"location": {"country": "geo.country.iso_code"}, "map": {"colorBy": "F1", "sizeBy": "A"}}`,
|
||||
},
|
||||
{
|
||||
name: "ClickHouseQuery_BindingsUnchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT country, errors"}}`,
|
||||
spec: `{"location": {"country": "country"}, "map": {"colorBy": "errors"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(geoMapDashboard(testCase.spec, testCase.queryPlugin))
|
||||
if testCase.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersesFixtureStorageRoundTrip exercises the typed → map[string]any →
|
||||
// typed cycle that the create/get path performs against the kitchen-sink
|
||||
// fixture. Catches plugin specs whose UnmarshalJSON expects a different shape
|
||||
@@ -2566,7 +2734,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel", "signoz/GeoMapPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2624,6 +2792,13 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"GeoMap+ClickHouse", mkQuery("signoz/GeoMapPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"GeoMap+PromQL", mkQuery("signoz/GeoMapPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"GeoMap+Formula", mkQuery("signoz/GeoMapPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"GeoMap+TraceOperator", mkQuery("signoz/GeoMapPanel", "signoz/TraceOperator", `{"name":"T1","expression":"A => B"}`), true},
|
||||
{"GeoMap+Composite(trace_operator)", mkComposite("signoz/GeoMapPanel", "builder_trace_operator", `{"name":"T1","expression":"A => B"}`), true},
|
||||
{"GeoMap+Composite(formula)", mkComposite("signoz/GeoMapPanel", "builder_formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"GeoMap+Composite(promql)", mkComposite("signoz/GeoMapPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -39,6 +39,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindGeoMap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[GeoMapPanelSpec]{Kind: string(PanelKindGeoMap)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +249,7 @@ var (
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindGeoMap: func() any { return new(GeoMapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -274,6 +277,7 @@ var (
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindGeoMap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind.joinsQueryRowsIntoOneTable(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot, PanelKindGeoMap} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
@@ -265,8 +265,11 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
Kind: QueryKindComposite,
|
||||
Spec: &CompositeQuerySpec{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "B"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -281,6 +284,7 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
assert.Len(t, req.CompositeQuery.Queries, 2)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -200,16 +200,21 @@ const (
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindGeoMap PanelPluginKind = "signoz/GeoMapPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot, PanelKindGeoMap}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
return k == PanelKindText
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) joinsQueryRowsIntoOneTable() bool {
|
||||
return k == PanelKindTable || k == PanelKindScatterPlot || k == PanelKindGeoMap
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization" description:"Time range and gap handling."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
@@ -372,8 +377,8 @@ func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) er
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
if err := s.validateQueriesShareGroupBy(composite, path); err != nil {
|
||||
return err
|
||||
if err := composite.ValidateQueriesShareGroupBy(); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
if err := s.validateValueDimensions(keys, path); err != nil {
|
||||
return err
|
||||
@@ -384,23 +389,6 @@ func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// Order is ignored since rows join on sorted labels; disabled queries count, as formulas carry their keys.
|
||||
func (*ScatterPlotPanelSpec) validateQueriesShareGroupBy(composite qb.CompositeQuery, path string) error {
|
||||
var queries []qb.QueryEnvelope
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.GetType() != qb.QueryTypeFormula {
|
||||
queries = append(queries, envelope)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(queries); i++ {
|
||||
first, other := groupByKeyNames(queries[0]), groupByKeyNames(queries[i])
|
||||
if !slices.Equal(first, other) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: queries must group by the same keys so their rows join into dots; %q groups by [%s], %q groups by [%s]", path, queries[0].GetQueryName(), allowedValuesForKind(first), queries[i].GetQueryName(), allowedValuesForKind(other))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateValueDimensions(keys qb.ResultColumnKeys, path string) error {
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
@@ -423,15 +411,6 @@ func (s *ScatterPlotPanelSpec) validateColorDimensions(keys qb.ResultColumnKeys,
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupByKeyNames(envelope qb.QueryEnvelope) []string {
|
||||
names := make([]string, 0, len(envelope.GetGroupBy()))
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
names = append(names, key.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
@@ -495,6 +474,162 @@ func (p ScatterPlotPoints) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the values are computed over."`
|
||||
Formatting TableFormatting `json:"formatting" description:"Per-column units and decimal precision."`
|
||||
Location GeoMapLocation `json:"location" description:"The group-by columns that place each row on the map."`
|
||||
Map GeoMapSettings `json:"map" description:"Layer, view, projection and the value columns drawn."`
|
||||
ChartAppearance GeoMapChartAppearance `json:"chartAppearance" description:"Color scale, bubble size and labels."`
|
||||
Legend Legend `json:"legend" description:"Legend placement."`
|
||||
Thresholds []GeoMapThreshold `json:"thresholds" validate:"dive" description:"Rules that recolor a shape, bubble or point when its value compares to a threshold as specified."`
|
||||
}
|
||||
|
||||
// A null radius bound is compared at the layer's renderer default, so a one-sided setting can't invert the range.
|
||||
func (s GeoMapPanelSpec) validate() error {
|
||||
minRadius, maxRadius := defaultBubbleMinRadius, defaultBubbleMaxRadius
|
||||
if s.Map.Layer == GeoMapLayerPoints {
|
||||
minRadius, maxRadius = defaultPointMinRadius, defaultPointMaxRadius
|
||||
}
|
||||
if s.ChartAppearance.Bubbles.MinRadius != nil {
|
||||
minRadius = *s.ChartAppearance.Bubbles.MinRadius
|
||||
}
|
||||
if s.ChartAppearance.Bubbles.MaxRadius != nil {
|
||||
maxRadius = *s.ChartAppearance.Bubbles.MaxRadius
|
||||
}
|
||||
if minRadius > maxRadius {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid bubbles: minRadius %d must not exceed maxRadius %d", minRadius, maxRadius)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GeoMapPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
if err := composite.ValidateQueriesShareGroupBy(); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"location.country", s.Location.Country}, {"location.region", s.Location.Region}, {"location.lat", s.Location.Lat}, {"location.lon", s.Location.Lon}} {
|
||||
// Empty is unset: the renderer detects the column by name.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Groups[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.%s: %q is not a group-by key of the panel's query; group-by keys: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"map.colorBy", s.Map.ColorBy}, {"map.sizeBy", s.Map.SizeBy}} {
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapLocation struct {
|
||||
Mode GeoMapLocationMode `json:"mode" required:"true" description:"How rows are placed: auto (default) detects it from the group-by keys, country, subdivision or coordinates."`
|
||||
Country string `json:"country" description:"Group-by key holding the country, for the country and subdivision modes. Empty detects it by name, such as geo.country.iso_code."`
|
||||
CountryFormat GeoMapCountryFormat `json:"countryFormat" required:"true" description:"How country values are written: auto (default) detects it, alpha2, alpha3 or name."`
|
||||
Region string `json:"region" description:"Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code), for the subdivision mode. Empty detects it by name."`
|
||||
Lat string `json:"lat" description:"Group-by key holding the latitude, for the coordinates mode. Empty detects it by name."`
|
||||
Lon string `json:"lon" description:"Group-by key holding the longitude, for the coordinates mode. Empty detects it by name."`
|
||||
}
|
||||
|
||||
type GeoMapSettings struct {
|
||||
Layer GeoMapLayer `json:"layer" required:"true" description:"What is drawn: regions (default) fills each shape, bubbles draws a circle per region, points a circle per coordinate."`
|
||||
View GeoMapView `json:"view" required:"true" description:"The area shown: world (default), fit to the data, a continent, or country with viewCountry."`
|
||||
ViewCountry string `json:"viewCountry" validate:"omitempty,iso3166_1_alpha2" description:"Uppercase ISO 3166-1 alpha-2 code of the country the country view shows."`
|
||||
Projection GeoMapProjection `json:"projection" required:"true" description:"The map projection: mercator (default), equal_earth or natural_earth."`
|
||||
ColorBy string `json:"colorBy" description:"Value column key, as in a Table panel, that sets the color. Empty uses the first value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key, as in a Table panel, that sets the bubble or point size. Empty uses colorBy."`
|
||||
}
|
||||
|
||||
func (m *GeoMapSettings) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias GeoMapSettings
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid map")
|
||||
}
|
||||
*m = GeoMapSettings(tmp)
|
||||
return m.validate()
|
||||
}
|
||||
|
||||
func (m GeoMapSettings) validate() error {
|
||||
if m.View == GeoMapViewCountry && m.ViewCountry == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid map: viewCountry is required when view is `country`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapChartAppearance struct {
|
||||
Colors GeoMapColors `json:"colors" description:"The color scale applied to the colorBy values."`
|
||||
Bubbles GeoMapBubbles `json:"bubbles" description:"The circle radius range of the bubbles and points layers."`
|
||||
Labels *bool `json:"labels" description:"Shows country and state names once each fits inside its shape. Null shows them."`
|
||||
}
|
||||
|
||||
type GeoMapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode" description:"How values are colored: palette (default) picks a hue from palette, opacity varies the opacity of fill."`
|
||||
Palette HeatmapPalette `json:"palette" description:"The named color ramp used in palette mode, such as lava (default), ice or ember."`
|
||||
Scale HeatmapColorScale `json:"scale" description:"How values map onto the color range: log (default), sqrt or linear."`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128" description:"The number of distinct color steps, 2 to 128. 0 uses the chart default."`
|
||||
Min *float64 `json:"min" description:"The value mapped to the lowest color. Null uses the lowest value in the data. Must not exceed max."`
|
||||
Max *float64 `json:"max" description:"The value mapped to the highest color. Null uses the highest value in the data."`
|
||||
Fill string `json:"fill" validate:"omitempty,hexcolor" description:"The hex color varied in opacity mode. Empty uses the default fill."`
|
||||
}
|
||||
|
||||
func (c *GeoMapColors) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias GeoMapColors
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map colors")
|
||||
}
|
||||
*c = GeoMapColors(tmp)
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
func (c GeoMapColors) validate() error {
|
||||
if c.Min != nil && c.Max != nil && *c.Min > *c.Max {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "geo map colors.min %v is greater than colors.max %v", *c.Min, *c.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapBubbles struct {
|
||||
MinRadius *BubbleRadius `json:"minRadius" description:"Radius of the smallest circle, in CSS pixels from 1 to 64. Null means 2 on the points layer, 3 otherwise."`
|
||||
MaxRadius *BubbleRadius `json:"maxRadius" description:"Radius of the largest circle, in CSS pixels from 1 to 64. Null means 8 on the points layer, 28 otherwise."`
|
||||
}
|
||||
|
||||
func (b *GeoMapBubbles) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias GeoMapBubbles
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid bubbles")
|
||||
}
|
||||
*b = GeoMapBubbles(tmp)
|
||||
return b.validate()
|
||||
}
|
||||
|
||||
func (b GeoMapBubbles) validate() error {
|
||||
if b.MinRadius != nil && b.MaxRadius != nil && *b.MinRadius > *b.MaxRadius {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid bubbles: minRadius %d must not exceed maxRadius %d", *b.MinRadius, *b.MaxRadius)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign" description:"Horizontal alignment: left (default), center or right."`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign" description:"Vertical alignment: top (default), center or bottom."`
|
||||
@@ -586,6 +721,17 @@ type TableThreshold struct {
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true" description:"The key of the value column the rule applies to: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Group-by columns are never colored, and aggregation aliases are display labels, not keys."`
|
||||
}
|
||||
|
||||
// GeoMapThreshold is ComparisonThreshold without format.
|
||||
type GeoMapThreshold struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true" description:"The value compared against, in unit."`
|
||||
Operator ComparisonOperator `json:"operator" description:"The comparison that activates the threshold: above (default), below, above_or_equal, below_or_equal, equal or not_equal."`
|
||||
Unit string `json:"unit" description:"The unit id of value, such as ms or percent. Empty means the colorBy column's unit."`
|
||||
Color string `json:"color" validate:"required" required:"true" description:"The CSS color of the shape, bubble or point while the threshold is active, for example #F1575F; CSS named colors such as red also work."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Constrained scalar types — with default value
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -1112,6 +1258,36 @@ func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minBubbleRadius = 1
|
||||
maxBubbleRadius = 64
|
||||
|
||||
defaultBubbleMinRadius BubbleRadius = 3
|
||||
defaultBubbleMaxRadius BubbleRadius = 28
|
||||
defaultPointMinRadius BubbleRadius = 2
|
||||
defaultPointMaxRadius BubbleRadius = 8
|
||||
)
|
||||
|
||||
// BubbleRadius is a geo map circle's radius in whole CSS pixels.
|
||||
type BubbleRadius int
|
||||
|
||||
func (BubbleRadius) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minBubbleRadius).WithMaximum(maxBubbleRadius)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BubbleRadius) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid bubble radius: must be a whole number between %d and %d", minBubbleRadius, maxBubbleRadius)
|
||||
}
|
||||
if v < minBubbleRadius || v > maxBubbleRadius {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid bubble radius %d: must be a whole number between %d and %d", v, minBubbleRadius, maxBubbleRadius)
|
||||
}
|
||||
*r = BubbleRadius(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
@@ -1499,3 +1675,201 @@ func (s *HeatmapColorScale) UnmarshalJSON(data []byte) error {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color scale %q: must be `log`, `sqrt`, or `linear`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapLocationMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapLocationModeAuto = GeoMapLocationMode{valuer.NewString("auto")} // default
|
||||
GeoMapLocationModeCountry = GeoMapLocationMode{valuer.NewString("country")}
|
||||
GeoMapLocationModeSubdivision = GeoMapLocationMode{valuer.NewString("subdivision")}
|
||||
GeoMapLocationModeCoordinates = GeoMapLocationMode{valuer.NewString("coordinates")}
|
||||
)
|
||||
|
||||
func (GeoMapLocationMode) Enum() []any {
|
||||
return []any{GeoMapLocationModeAuto, GeoMapLocationModeCountry, GeoMapLocationModeSubdivision, GeoMapLocationModeCoordinates}
|
||||
}
|
||||
|
||||
func (v GeoMapLocationMode) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapLocationModeAuto.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapLocationMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapLocationMode) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map location mode: must be a string, one of `auto`, `country`, `subdivision`, or `coordinates`")
|
||||
}
|
||||
parsed := GeoMapLocationMode{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapLocationModeAuto, GeoMapLocationModeCountry, GeoMapLocationModeSubdivision, GeoMapLocationModeCoordinates:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map location mode %q: must be `auto`, `country`, `subdivision`, or `coordinates`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapCountryFormat struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapCountryFormatAuto = GeoMapCountryFormat{valuer.NewString("auto")} // default
|
||||
GeoMapCountryFormatAlpha2 = GeoMapCountryFormat{valuer.NewString("alpha2")}
|
||||
GeoMapCountryFormatAlpha3 = GeoMapCountryFormat{valuer.NewString("alpha3")}
|
||||
GeoMapCountryFormatName = GeoMapCountryFormat{valuer.NewString("name")}
|
||||
)
|
||||
|
||||
func (GeoMapCountryFormat) Enum() []any {
|
||||
return []any{GeoMapCountryFormatAuto, GeoMapCountryFormatAlpha2, GeoMapCountryFormatAlpha3, GeoMapCountryFormatName}
|
||||
}
|
||||
|
||||
func (v GeoMapCountryFormat) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapCountryFormatAuto.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapCountryFormat) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapCountryFormat) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map country format: must be a string, one of `auto`, `alpha2`, `alpha3`, or `name`")
|
||||
}
|
||||
parsed := GeoMapCountryFormat{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapCountryFormatAuto, GeoMapCountryFormatAlpha2, GeoMapCountryFormatAlpha3, GeoMapCountryFormatName:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map country format %q: must be `auto`, `alpha2`, `alpha3`, or `name`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapLayer struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapLayerRegions = GeoMapLayer{valuer.NewString("regions")} // default
|
||||
GeoMapLayerBubbles = GeoMapLayer{valuer.NewString("bubbles")}
|
||||
GeoMapLayerPoints = GeoMapLayer{valuer.NewString("points")}
|
||||
)
|
||||
|
||||
func (GeoMapLayer) Enum() []any {
|
||||
return []any{GeoMapLayerRegions, GeoMapLayerBubbles, GeoMapLayerPoints}
|
||||
}
|
||||
|
||||
func (v GeoMapLayer) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapLayerRegions.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapLayer) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapLayer) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map layer: must be a string, one of `regions`, `bubbles`, or `points`")
|
||||
}
|
||||
parsed := GeoMapLayer{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapLayerRegions, GeoMapLayerBubbles, GeoMapLayerPoints:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map layer %q: must be `regions`, `bubbles`, or `points`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapView struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapViewWorld = GeoMapView{valuer.NewString("world")} // default
|
||||
GeoMapViewFit = GeoMapView{valuer.NewString("fit")}
|
||||
GeoMapViewAfrica = GeoMapView{valuer.NewString("africa")}
|
||||
GeoMapViewAsia = GeoMapView{valuer.NewString("asia")}
|
||||
GeoMapViewEurope = GeoMapView{valuer.NewString("europe")}
|
||||
GeoMapViewNorthAmerica = GeoMapView{valuer.NewString("north_america")}
|
||||
GeoMapViewSouthAmerica = GeoMapView{valuer.NewString("south_america")}
|
||||
GeoMapViewOceania = GeoMapView{valuer.NewString("oceania")}
|
||||
GeoMapViewCountry = GeoMapView{valuer.NewString("country")}
|
||||
)
|
||||
|
||||
func (GeoMapView) Enum() []any {
|
||||
return []any{GeoMapViewWorld, GeoMapViewFit, GeoMapViewAfrica, GeoMapViewAsia, GeoMapViewEurope, GeoMapViewNorthAmerica, GeoMapViewSouthAmerica, GeoMapViewOceania, GeoMapViewCountry}
|
||||
}
|
||||
|
||||
func (v GeoMapView) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapViewWorld.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapView) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapView) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map view: must be a string, one of `world`, `fit`, `africa`, `asia`, `europe`, `north_america`, `south_america`, `oceania`, or `country`")
|
||||
}
|
||||
parsed := GeoMapView{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapViewWorld, GeoMapViewFit, GeoMapViewAfrica, GeoMapViewAsia, GeoMapViewEurope, GeoMapViewNorthAmerica, GeoMapViewSouthAmerica, GeoMapViewOceania, GeoMapViewCountry:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map view %q: must be `world`, `fit`, `africa`, `asia`, `europe`, `north_america`, `south_america`, `oceania`, or `country`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapProjection struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapProjectionMercator = GeoMapProjection{valuer.NewString("mercator")} // default
|
||||
GeoMapProjectionEqualEarth = GeoMapProjection{valuer.NewString("equal_earth")}
|
||||
GeoMapProjectionNaturalEarth = GeoMapProjection{valuer.NewString("natural_earth")}
|
||||
)
|
||||
|
||||
func (GeoMapProjection) Enum() []any {
|
||||
return []any{GeoMapProjectionMercator, GeoMapProjectionEqualEarth, GeoMapProjectionNaturalEarth}
|
||||
}
|
||||
|
||||
func (p GeoMapProjection) ValueOrDefault() string {
|
||||
if p.IsZero() {
|
||||
return GeoMapProjectionMercator.StringValue()
|
||||
}
|
||||
return p.StringValue()
|
||||
}
|
||||
|
||||
func (p GeoMapProjection) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (p *GeoMapProjection) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map projection: must be a string, one of `mercator`, `equal_earth`, or `natural_earth`")
|
||||
}
|
||||
parsed := GeoMapProjection{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapProjectionMercator, GeoMapProjectionEqualEarth, GeoMapProjectionNaturalEarth:
|
||||
*p = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map projection %q: must be `mercator`, `equal_earth`, or `natural_earth`", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package querybuildertypesv5
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/govaluate"
|
||||
@@ -305,6 +306,35 @@ func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// ValidateQueriesShareGroupBy rejects queries whose rows cannot join on their
|
||||
// group-by keys. Order is ignored since rows join on sorted labels; disabled
|
||||
// queries count, as formulas carry their keys.
|
||||
func (c *CompositeQuery) ValidateQueriesShareGroupBy() error {
|
||||
var queries []QueryEnvelope
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() != QueryTypeFormula {
|
||||
queries = append(queries, envelope)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(queries); i++ {
|
||||
first, other := queries[0].GetGroupByKeyNames(), queries[i].GetGroupByKeyNames()
|
||||
slices.Sort(first)
|
||||
slices.Sort(other)
|
||||
if !slices.Equal(first, other) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "queries must group by the same keys so their rows join; %q groups by [%s], %q groups by [%s]", queries[0].GetQueryName(), quoteKeyNames(first), queries[i].GetQueryName(), quoteKeyNames(other))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quoteKeyNames(names []string) string {
|
||||
quoted := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
quoted[i] = "`" + name + "`"
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,14 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QueryEnvelope) GetGroupByKeyNames() []string {
|
||||
names := make([]string, 0, len(q.GetGroupBy()))
|
||||
for _, key := range q.GetGroupBy() {
|
||||
names = append(names, key.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
|
||||
Reference in New Issue
Block a user