Compare commits

..

102 Commits

Author SHA1 Message Date
Abhi Kumar
f1c633501f feat(charts): draw the geo map in Equal Earth or Natural Earth
OpenLayers projections are registered from d3-geo's raw formulas, so no proj4.
2026-10-17 00:01:13 +05:30
Abhi Kumar
10c4a1438c fix(charts): adopt @signozhq/ui 0.2.0 in the geo map
Zoom buttons use icon mode with a wrapping tooltip and say why they are
disabled. GeoJSON types are imported, no longer read off a global.
2026-10-17 00:01:08 +05:30
Abhi Kumar
1c7df370ca feat(charts): show a geo map location's own values in its tooltip
Rank and share of total are dropped: they mean nothing for averages and
rates. A point without a label is named by the shape it falls in, and the
legend groups its colour bar, swatch keys and size circles.
2026-10-17 00:01:08 +05:30
Abhi Kumar
b27555aa1c feat(charts): place the geo map legend below or right of the map
On the right the colour bar runs vertically and the size legend stacks.
2026-10-17 00:01:07 +05:30
Abhi Kumar
cae293ed9d feat(charts): color geo map locations by opacity or their own color
A location can carry its own color, keyed in the legend, for thresholds.
2026-10-17 00:01:07 +05:30
Abhi Kumar
a671a4fe8b fix(charts): normalise geo codes without loading the outlines
The lookups carry the drawn country codes, and loaded states files are
readable from a cache, so data code never pulls in the map assets.
2026-10-17 00:01:07 +05:30
Abhi Kumar
868a8e83fa feat(charts): draw geo map points on the GPU, up to 100k
The WebGL layer loads only when a map shows points, so region and bubble maps don't pay for it;
without WebGL, points fall back to canvas.
2026-10-17 00:01:07 +05:30
Abhi Kumar
03650492ff feat(charts): draw lat/lon points on the geo map
Points reuse the bubble scales keyed by id, so colour, size and the second metric carry over.
2026-10-17 00:01:07 +05:30
Abhi Kumar
674e8db63b feat(charts): label geo map shapes by name
A name shows only once it fits inside its shape, so smaller ones appear as you zoom in.
2026-10-17 00:01:07 +05:30
Abhi Kumar
1699575d00 feat(charts): size geo map bubbles by a second metric
Colour keeps one meaning: on regions the size bubbles are neutral, and a size legend at the
map's own scale names the second metric.
2026-10-17 00:01:07 +05:30
Abhi Kumar
4f8edf5ad8 refactor(charts): split the geo outlines build and validate its output
Builders are pure; downloads retry, every invariant is checked before writing, and the assets
are staged and swapped in whole so a failed run leaves the committed ones untouched.
2026-10-17 00:01:07 +05:30
Abhi Kumar
c1940b8404 fix(charts): place Northern Ireland and Metro Manila GeoIP codes
The geoipprocessor reports MaxMind's most specific subdivision, which matches Natural Earth's
level everywhere except Northern Ireland's 2015 councils and Metro Manila's current code.
2026-10-17 00:01:07 +05:30
Abhi Kumar
72a457dc14 feat(charts): ship states for every country
Codes Natural Earth splits across shapes are merged into one, and a country is framed by
its largest landmass plus the sizeable polygons near it, leaving far territories out.
2026-10-17 00:01:07 +05:30
Abhi Kumar
d4640fadde fix(charts): simplify geo outlines by a fixed area
topojson-simplify ranks weights high to low, so the quantiles kept 5% of country points
and 98.5% of state points: islands collapsed to slivers and the state files were bloated.
2026-10-17 00:01:07 +05:30
Abhi Kumar
948b0034da fix(charts): keep low geo map values off the background colour
The palette's faint end and the zero fill both shade toward the theme's background,
which suits the heatmap's grid but hides countries on a map.
2026-10-17 00:01:07 +05:30
Abhi Kumar
6a1a9b1b07 fix(charts): match the geo map tooltip and drilldown to other charts
OpenLayers reports a click on pointerup, before the DOM click that closes an
open popover, so the selection now waits for that DOM click.
2026-10-17 00:01:07 +05:30
Abhi Kumar
9d9a98958b feat(charts): draw the geo map with OpenLayers
Smoother zoom and room for large point counts and an optional tile layer, matching Datadog
and Grafana. The visx SVG version is dropped; jsdom has no canvas, so interactions are
covered by hook tests with a fake map and Storybook play tests.
2026-10-17 00:01:07 +05:30
Abhi Kumar
c787ada845 feat(charts): add the geo map chart
Draws bundled Natural Earth outlines as SVG instead of a tile basemap, so it needs no
network or keys and works air-gapped. States are split per country and lazy-loaded.
2026-10-17 00:01:07 +05:30
Abhi Kumar
43a4720e60 fix(charts): start the heatmap's log colour bar at its floor
A log scale can't place 0, so the bar's bottom label now shows the value the ramp
actually starts at.
2026-10-17 00:01:07 +05:30
Abhi Kumar
631b69cff8 refactor(charts): share the heatmap colour ramp
The geo map reuses the palettes and scales, so they move out of the
uPlot heatmap plugin under generic names. Stored panel specs are unchanged.
2026-10-17 00:01:07 +05:30
Abhi Kumar
6ee46d6ceb chore(openapi): regenerate specs 2026-10-17 00:00:55 +05:30
Abhi Kumar
2e097e122d feat(dashboards): add a projection to the geo map spec
Required in the schema with a mercator default, so every saved panel carries one.
2026-10-17 00:00:53 +05:30
Abhi Kumar
17b740015a chore(openapi): regenerate specs 2026-10-16 00:53:20 +05:30
Abhi Kumar
38d1b69fd4 feat(dashboards): add opacity mode to geo map colors 2026-10-16 00:53:20 +05:30
Abhi Kumar
cd3ae16360 chore(openapi): regenerate specs 2026-10-16 00:53:20 +05:30
Abhi Kumar
a82c241ea2 feat(dashboards): add plugin schema for geo map panel 2026-10-16 00:53:20 +05:30
Abhi Kumar
9b40cf8b5c fix(dashboard): use the @signozhq/ui 0.2.0 tooltip in the top list header 2026-10-16 00:53:04 +05:30
Abhi Kumar
e9235a10fd fix(dashboard): declare top list query modes 2026-10-16 00:45:52 +05:30
Abhi Kumar
68476e25f7 feat(dashboard): head the top list's columns and let users pick its bar color
Bar color lives in the spec. An unset color resolves to the robin default
server-side, so existing panels stay valid.
2026-10-16 00:45:52 +05:30
Abhi Kumar
5ba7a4c86c feat(dashboard): warn when a top list shows shares of a non-additive value
Formulas and ClickHouse SQL get no warning, since their aggregation can't be read.
2026-10-16 00:45:52 +05:30
Abhi Kumar
b7b3e48a5d fix(dashboard): hide the top list tooltip off its rows
The empty space below the rows is still inside the list, so the tooltip stayed on the last row.
2026-10-16 00:45:52 +05:30
Abhi Kumar
ab4bb5a6a5 feat(dashboard): hint the top list's default order 2026-10-16 00:45:52 +05:30
Abhi Kumar
ab5d035022 feat(dashboard): show rank, share and a row tooltip in top lists
Share is of the listed rows' total, not the whole result, to avoid a second query.
Labels are cut in the middle so both ends stay readable; the tooltip has them in full.
2026-10-16 00:45:31 +05:30
Abhi Kumar
87ec6e9cf8 feat(dashboard): add a top list appearance section with rank and share toggles 2026-10-16 00:45:31 +05:30
Abhi Kumar
8d93e46b2a feat(charts): let a tooltip footer leave out the pin hint 2026-10-16 00:45:31 +05:30
Abhi Kumar
af55cc43cc fix(charts): use theme text colours in the tooltip card
The vanilla tokens are white in both themes, so the text vanished in light mode.
2026-10-16 00:45:31 +05:30
Abhi Kumar
11d91eb9eb refactor(charts): extract the scatter tooltip card for reuse 2026-10-16 00:45:31 +05:30
Abhi Kumar
917e5da2b7 feat(dashboard): pin order by and limit in the top list query builder
Top or bottom N is picked on the server, so the order and limit are the panel's sort controls.
2026-10-16 00:45:31 +05:30
Abhi Kumar
de3d950716 feat(query-builder): show the default limit placeholder only for a lone query
A panel's default limit applies only to a lone query, so the placeholder falls back to
'Enter limit' once formulas or more queries are added.
2026-10-16 00:45:31 +05:30
Abhi Kumar
bdf7e1708b chore(api): regenerate client types 2026-10-16 00:42:03 +05:30
Abhi Kumar
76489f4b43 feat(dashboard): rank top lists across multiple queries and formulas
Ranks the first enabled result in builder order, since the response lists
results in no particular order. Rows follow the query's value order, so an
ascending order gives a bottom N.
2026-10-16 00:42:03 +05:30
Abhi Kumar
6bf9153612 feat(dashboard): fill top list bars with their labels and fit row density
Rows pick the roomiest of 32, 28 and 24px that fits every row in the panel.
2026-10-16 00:42:03 +05:30
Abhi Kumar
2962c900cc feat(dashboard): download top list panels as csv 2026-10-16 00:42:03 +05:30
Abhi Kumar
b1bcca1332 feat(dashboard): warn when a top list can't rank its result 2026-10-16 00:42:02 +05:30
Abhi Kumar
e3f53736ed feat(dashboard): add the top list panel
Ranks one query's single aggregation for now. Rows share one grid so bars
align, and the list is virtualized.
2026-10-16 00:42:02 +05:30
Abhi Kumar
7b05bc3ed4 chore(api): regenerate client types 2026-10-16 00:42:02 +05:30
Abhi Kumar
38fe40bbec chore(openapi): regenerate specs 2026-10-16 00:42:02 +05:30
Abhi Kumar
d4fb1bfcfc feat(dashboards): add a bar color to the top list spec
An unset color resolves to the robin default, so existing panels stay valid.
2026-10-16 00:42:02 +05:30
Abhi Kumar
f56517abd4 chore(openapi): regenerate specs 2026-10-16 00:42:02 +05:30
Abhi Kumar
135097d609 feat(dashboards): add rank and share toggles to the top list spec 2026-10-16 00:42:01 +05:30
Abhi Kumar
230d24416f feat(dashboards): allow formulas and trace operators in top list panels 2026-10-16 00:42:01 +05:30
Abhi Kumar
652e765f88 chore(openapi): regenerate specs 2026-10-16 00:42:01 +05:30
Abhi Kumar
7ee496c537 feat(dashboards): add plugin schema for top list panel 2026-10-16 00:42:01 +05:30
Abhi Kumar
e60d42799e fix(dashboard): drop PromQL from scatter plot query types 2026-10-16 00:41:49 +05:30
Abhi Kumar
2ec0e0d9ca fix(dashboard): declare scatter plot query modes 2026-10-16 00:41:49 +05:30
Abhi Kumar
90825d7794 refactor(dashboard): trim scatter plot panel comments and share the joined table helper 2026-10-16 00:41:49 +05:30
Abhi Kumar
2dbf2982e5 fix(dashboard): warn of the scatter plot cap only when groups were capped 2026-10-16 00:41:49 +05:30
Abhi Kumar
4ffb8ee309 feat(dashboard): let scatter plot thresholds mark the x or y axis 2026-10-16 00:41:49 +05:30
Abhi Kumar
6df6399de6 feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-16 00:41:49 +05:30
Abhi Kumar
64009bddd2 feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-16 00:41:49 +05:30
Abhi Kumar
ae8b337a7a refactor(dashboard): move scatter plot panel helpers into utils 2026-10-16 00:41:49 +05:30
Abhi Kumar
c4829af7da style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-16 00:41:49 +05:30
Abhi Kumar
f97d362587 fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-16 00:41:49 +05:30
Abhi Kumar
6c64cb83c6 feat(dashboard): sync scatter plot group by across queries
Panel kinds declare which builder fields to keep equal across queries. A query
added or switched to another signal takes the others' values, since a signal
switch reloads its own.
2026-10-16 00:41:49 +05:30
Abhi Kumar
1f82c9822b feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-16 00:41:48 +05:30
Abhi Kumar
cf08a5f54c feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-16 00:41:48 +05:30
Abhi Kumar
e29c879cf7 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-16 00:41:48 +05:30
Abhi Kumar
ef096b537e fix(dashboard): limit scatter plot queries to 10,000 groups on the server
Only a lone builder query gets the default: per-query limits on several
queries can keep different groups and break the join.
2026-10-16 00:41:48 +05:30
Abhi Kumar
1a9153724c fix(dashboard): cap the scatter plot at 10,000 dots
A canvas path of a few hundred thousand dots fails to paint and takes the later series with it.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
010b850bb9 fix(dashboard): redraw the scatter plot after an empty state
The chart area stays mounted, since the size observer binds to its node only once.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
3eaddaca62 refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
7ea347985c feat(dashboard): lay out scatter plot axes with label, range and scale rows
Range help says when a log axis ignores a bound at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
1669fdec4b feat(dashboard): restyle scatter plot dimensions with field info and auto labels
Colour is a multi-select of group-by keys; Auto options name the column they resolve to.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
dd6a951751 feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:48 +05:30
Abhi Kumar
5c00cfb92e fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
e84c5c2a9e feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
a7f92f9e87 feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
2b411943ab fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
1e21c16d1d fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
b15f1e2d0a feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
28afe94a79 feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
fd728d75a9 refactor(charts): trim scatter chart layer comments and dead code 2026-10-16 00:41:47 +05:30
Abhi Kumar
9e28082b99 fix(charts): pick the scatter auto scale by how the points spread
Max/min decades let one outlier decide and never left linear with zeros.
Compare middle-half coverage instead, falling back to symlog.
2026-10-16 00:41:47 +05:30
Abhi Kumar
3e267ad40f fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:47 +05:30
Abhi Kumar
78b3d4c233 fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
6f529e7864 feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
6d91bc2df1 fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
800dda2846 fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
a8fb335f2e feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
c235957201 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
40314259dd fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
bf61ab1b65 feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:46 +05:30
Abhi Kumar
a143e89fff chore: scatter plot base work 2026-10-16 00:41:46 +05:30
Abhi Kumar
639e091892 feat(dashboards): add an axis to scatter plot thresholds 2026-10-16 00:41:46 +05:30
Naman Verma
2bb9496b35 chore: add more validations for scatter plot 2026-10-16 00:41:45 +05:30
Abhi Kumar
251855873d feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-16 00:41:45 +05:30
Abhi Kumar
69d94733ce chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
a83b78d401 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
214d8e4d58 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
74f1a1be42 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
658b2a654b chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
088d55cca0 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
658 changed files with 22690 additions and 7856 deletions

View File

@@ -104,7 +104,6 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -3702,6 +3702,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -3732,6 +3739,11 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBubbleRadius:
maximum: 64
minimum: 1
nullable: true
type: integer
DashboardtypesBuilderQuerySpec:
discriminator:
mapping:
@@ -3904,6 +3916,159 @@ components:
minimum: 0
nullable: true
type: number
DashboardtypesGeoMapBubbles:
properties:
maxRadius:
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
minRadius:
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
type: object
DashboardtypesGeoMapChartAppearance:
properties:
bubbles:
$ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
colors:
$ref: '#/components/schemas/DashboardtypesGeoMapColors'
labels:
description: Shows country and state names once each fits inside its shape.
nullable: true
type: boolean
type: object
DashboardtypesGeoMapColors:
properties:
fill:
type: string
max:
nullable: true
type: number
min:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
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.
type: string
countryFormat:
$ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
lat:
description: Group-by key holding the latitude, for the coordinates mode.
type: string
lon:
description: Group-by key holding the longitude, for the coordinates mode.
type: string
mode:
$ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
region:
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
for the subdivision mode.
type: string
type: object
DashboardtypesGeoMapLocationMode:
enum:
- auto
- country
- subdivision
- coordinates
type: string
DashboardtypesGeoMapPanelSpec:
properties:
chartAppearance:
$ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
location:
$ref: '#/components/schemas/DashboardtypesGeoMapLocation'
map:
$ref: '#/components/schemas/DashboardtypesGeoMapSettings'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesGeoMapProjection:
enum:
- mercator
- equal_earth
- natural_earth
type: string
DashboardtypesGeoMapSettings:
properties:
colorBy:
description: Value column key that sets the colour. Empty uses the first
value column.
type: string
layer:
$ref: '#/components/schemas/DashboardtypesGeoMapLayer'
projection:
$ref: '#/components/schemas/DashboardtypesGeoMapProjection'
sizeBy:
description: Value column key that sets the bubble or point size. Empty
uses colorBy.
type: string
view:
$ref: '#/components/schemas/DashboardtypesGeoMapView'
viewCountry:
description: ISO 3166-1 alpha-2 code of the country the country view shows.
type: string
required:
- projection
type: object
DashboardtypesGeoMapThreshold:
properties:
color:
type: string
operator:
$ref: '#/components/schemas/DashboardtypesComparisonOperator'
unit:
type: string
value:
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:
@@ -4439,14 +4604,17 @@ 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'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4459,6 +4627,9 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4472,6 +4643,9 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/TopListPanel
- signoz/GeoMapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4497,6 +4671,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
properties:
kind:
enum:
- signoz/GeoMapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
@@ -4557,6 +4743,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4593,6 +4791,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
properties:
kind:
enum:
- signoz/TopListPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
@@ -4636,6 +4846,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4824,6 +5044,104 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -4937,6 +5255,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -5008,6 +5331,35 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesTopListAppearance:
properties:
barColor:
description: Hex color of every bar; a matching background threshold overrides
it.
type: string
showRank:
description: Numbers each row by its position in the list.
type: boolean
showShare:
description: Shows each row's share of the listed rows' total.
type: boolean
required:
- barColor
type: object
DashboardtypesTopListPanelSpec:
properties:
appearance:
$ref: '#/components/schemas/DashboardtypesTopListAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
@@ -8046,31 +8398,6 @@ components:
- metric
- value
type: object
PromotetypesIndexMaterializedPathsResult:
properties:
indexed:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
skipped:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
required:
- indexed
- skipped
type: object
PromotetypesPostableIndexMaterializedPaths:
properties:
dryRun:
type: boolean
signal:
type: string
required:
- signal
type: object
PromotetypesPromotePath:
properties:
context:
@@ -14097,67 +14424,6 @@ paths:
tags:
- promote
x-signoz-stability: alpha
/api/v1/promoted_paths/materialized:
post:
deprecated: false
description: This endpoint indexes the JSON sub-columns of materialized paths
whose columns are indexed and returns the indexed and skipped paths.
operationId: IndexMaterializedPaths
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- logs-field:update
- traces-field:update
- tokenizer:
- logs-field:update
- traces-field:update
summary: Index materialized paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false
@@ -16150,8 +16416,10 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get stats
tags:
- stats
@@ -19000,9 +19268,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Run Infra Monitoring Setup Checks
tags:
- inframonitoring
@@ -19074,9 +19342,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
@@ -19158,9 +19426,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List DaemonSets for Infra Monitoring
tags:
- inframonitoring
@@ -19236,9 +19504,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Deployments for Infra Monitoring
tags:
- inframonitoring
@@ -19304,9 +19572,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
@@ -19386,9 +19654,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
@@ -19467,9 +19735,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
@@ -19538,9 +19806,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Namespaces for Infra Monitoring
tags:
- inframonitoring
@@ -19613,9 +19881,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
@@ -19689,9 +19957,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Pods for Infra Monitoring
tags:
- inframonitoring
@@ -19760,9 +20028,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Volumes for Infra Monitoring
tags:
- inframonitoring
@@ -19838,9 +20106,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List StatefulSets for Infra Monitoring
tags:
- inframonitoring
@@ -25008,75 +25276,6 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

View File

@@ -423,7 +423,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
AlertName: r.Name(),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),

View File

@@ -46,7 +46,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test anomaly no data"),
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -149,7 +149,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test anomaly no data with AbsentFor"),
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -119,7 +118,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
parsedRule := opts.Rule
var alertname = parsedRule.AlertName.StringValue()
var alertname = parsedRule.AlertName
if alertname == "" {
// alertname is not mandatory for testing, so picking
// a random string here
@@ -127,7 +126,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// append name to indicate this is test alert
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
var rule baserules.Rule
var err error

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -30,7 +30,8 @@
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'",
"build:geo-outlines": "node scripts/geo-outlines/build.mjs"
},
"engines": {
"node": ">=22.0.0",
@@ -78,6 +79,7 @@
"classnames": "2.3.2",
"color": "^4.2.1",
"crypto-js": "4.2.0",
"d3-geo": "3.1.1",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.15",
@@ -96,6 +98,7 @@
"monaco-editor": "0.55.1",
"motion": "12.4.13",
"nuqs": "2.8.8",
"ol": "10.11.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
@@ -132,6 +135,7 @@
"rrule": "2.8.1",
"styled-components": "^5.3.11",
"timestamp-nano": "^1.0.0",
"topojson-client": "3.1.0",
"translation-resilience": "^0.2.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
@@ -175,6 +179,7 @@
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/geojson": "7946.0.16",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
@@ -192,6 +197,8 @@
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/topojson-client": "3.1.5",
"@types/topojson-specification": "1.0.5",
"@types/uuid": "^8.3.1",
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"eslint-plugin-sonarjs": "4.0.2",
@@ -216,6 +223,8 @@
"storybook": "10.5.9",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"topojson-server": "3.0.1",
"topojson-simplify": "3.0.3",
"ts-jest": "29.4.9",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",

191
frontend/pnpm-lock.yaml generated
View File

@@ -149,6 +149,9 @@ importers:
crypto-js:
specifier: 4.2.0
version: 4.2.0
d3-geo:
specifier: 3.1.1
version: 3.1.1
d3-hierarchy:
specifier: 3.1.2
version: 3.1.2
@@ -203,6 +206,9 @@ importers:
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
ol:
specifier: 10.11.0
version: 10.11.0
overlayscrollbars:
specifier: ^2.16.0
version: 2.16.0
@@ -311,6 +317,9 @@ importers:
timestamp-nano:
specifier: ^1.0.0
version: 1.0.1
topojson-client:
specifier: 3.1.0
version: 3.1.0
translation-resilience:
specifier: ^0.2.0
version: 0.2.0
@@ -399,6 +408,9 @@ importers:
'@types/event-source-polyfill':
specifier: ^1.0.0
version: 1.0.1
'@types/geojson':
specifier: 7946.0.16
version: 7946.0.16
'@types/history':
specifier: 4.7.11
version: 4.7.11
@@ -450,6 +462,12 @@ importers:
'@types/testing-library__jest-dom':
specifier: ^5.14.5
version: 5.14.5
'@types/topojson-client':
specifier: 3.1.5
version: 3.1.5
'@types/topojson-specification':
specifier: 1.0.5
version: 1.0.5
'@types/uuid':
specifier: ^8.3.1
version: 8.3.4
@@ -522,6 +540,12 @@ importers:
svgo:
specifier: 4.1.0
version: 4.1.0
topojson-server:
specifier: 3.0.1
version: 3.0.1
topojson-simplify:
specifier: 3.0.3
version: 3.0.3
ts-jest:
specifier: 29.4.9
version: 29.4.9(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3)
@@ -2920,6 +2944,9 @@ packages:
'@petamoriken/float16@3.8.0':
resolution: {integrity: sha512-AhVAm6SQ+zgxIiOzwVdUcDmKlu/qU39FiYD2UD6kQQaVenrn0dGZewIghWAENGQsvC+1avLCuT+T2/3Gsp/W3w==}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -4040,8 +4067,8 @@ packages:
'@types/file-saver@2.0.5':
resolution: {integrity: sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==}
'@types/geojson@7946.0.14':
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/graceful-fs@4.1.6':
resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
@@ -4124,6 +4151,9 @@ packages:
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/rbush@4.0.0':
resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==}
'@types/react-addons-update@0.14.21':
resolution: {integrity: sha512-HOxr0Hd8C1L4uw8DHyv2etqMVIj78oLEpe567/HgjoE+1Lc+PUsTGXTrkr1BDvFqsu5r49mSlgI5evwrk9eutA==}
@@ -4178,6 +4208,12 @@ packages:
'@types/testing-library__jest-dom@5.14.5':
resolution: {integrity: sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==}
'@types/topojson-client@3.1.5':
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
'@types/topojson-specification@1.0.5':
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -4466,6 +4502,9 @@ packages:
'@xobotyi/scrollbar-width@1.9.5':
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
'@zarrita/storage@0.2.0':
resolution: {integrity: sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
@@ -5299,6 +5338,10 @@ packages:
resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-hierarchy@1.1.9:
resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==}
@@ -5591,6 +5634,9 @@ packages:
earcut@2.2.4:
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
earcut@3.2.4:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -5863,6 +5909,9 @@ packages:
fflate@0.4.9:
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@@ -6043,6 +6092,10 @@ packages:
resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==}
engines: {node: '>=10.19'}
geotiff@3.1.0-beta.0:
resolution: {integrity: sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==}
engines: {node: '>=10.19'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -7646,6 +7699,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
numcodecs@0.3.2:
resolution: {integrity: sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==}
nuqs@2.8.8:
resolution: {integrity: sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==}
peerDependencies:
@@ -7722,6 +7778,9 @@ packages:
ol-mapbox-style@10.7.0:
resolution: {integrity: sha512-S/UdYBuOjrotcR95Iq9AejGYbifKeZE85D9VtH11ryJLQPTZXZSW1J5bIXcr4AlAH6tyjPPHTK34AdkwB32Myw==}
ol@10.11.0:
resolution: {integrity: sha512-OJ+lrx7kx/1O7Xx18Ff9QkVnwZIEL0OuhJQSy2sYgzzDvyZ2qHvuZEGAajbSstUyBwaS1OtQ4RWnY84tRMDdqg==}
ol@7.4.0:
resolution: {integrity: sha512-bgBbiah694HhC0jt8ptEFNRXwgO8d6xWH3G97PCg4bmn9Li5nLLbi59oSrvqUI6VPVwonPQF1YcqJymxxyMC6A==}
@@ -7952,6 +8011,10 @@ packages:
resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==}
hasBin: true
pbf@5.1.2:
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -8157,12 +8220,18 @@ packages:
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
rbush@4.0.1:
resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==}
rc-cascader@3.20.0:
resolution: {integrity: sha512-lkT9EEwOcYdjZ/jvhLoXGzprK1sijT3/Tp4BLxQQcHDZkkOzzwYQC9HgmKoJz0K7CukMfgvO9KqHeBdgE+pELw==}
peerDependencies:
@@ -8730,6 +8799,9 @@ packages:
resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
reference-spec-reader@0.2.0:
resolution: {integrity: sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==}
refractor@3.6.0:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
@@ -9425,6 +9497,18 @@ packages:
toggle-selection@1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
topojson-client@3.1.0:
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
hasBin: true
topojson-server@3.0.1:
resolution: {integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==}
hasBin: true
topojson-simplify@3.0.3:
resolution: {integrity: sha512-V+pBjLVzSQ3+hSOxBiV01OVXgFiCmMO8ia3huxKEyIMTC1ApQHBcdXdOqcQ6U2JJJD31TZduwY6KyF15R8sUgg==}
hasBin: true
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
@@ -9670,6 +9754,10 @@ packages:
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
unzipit@2.0.0:
resolution: {integrity: sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==}
engines: {node: '>=18'}
update-browserslist-db@1.3.3:
resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
hasBin: true
@@ -9998,6 +10086,9 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
xml-utils@1.10.2:
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==}
xml-utils@1.7.0:
resolution: {integrity: sha512-bWB489+RQQclC7A9OW8e5BzbT8Tu//jtAOvkYwewFr+Q9T9KDGvfzC1lp0pYPEQPEoPQLDkmxkepSC/2gIAZGw==}
@@ -10080,12 +10171,18 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zarrita@0.7.5:
resolution: {integrity: sha512-ZriOSEne+Wf2iuM0yjDZvUKpHNlug3dO7qJ6mAxXoOgsYV18DPOkAkwB9e0rf21bHcKcOffFuwoZwn5g6pvUeA==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==}
zstddec@0.2.0:
resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
@@ -12664,6 +12761,8 @@ snapshots:
'@petamoriken/float16@3.8.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -13775,7 +13874,7 @@ snapshots:
'@types/d3-geo@3.1.0':
dependencies:
'@types/geojson': 7946.0.14
'@types/geojson': 7946.0.16
'@types/d3-hierarchy@1.1.11': {}
@@ -13815,7 +13914,7 @@ snapshots:
'@types/file-saver@2.0.5': {}
'@types/geojson@7946.0.14': {}
'@types/geojson@7946.0.16': {}
'@types/graceful-fs@4.1.6':
dependencies:
@@ -13904,6 +14003,8 @@ snapshots:
'@types/prop-types@15.7.5': {}
'@types/rbush@4.0.0': {}
'@types/react-addons-update@0.14.21':
dependencies:
'@types/react': 18.0.26
@@ -13978,6 +14079,15 @@ snapshots:
dependencies:
'@types/jest': 30.0.0
'@types/topojson-client@3.1.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-specification@1.0.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7':
@@ -14275,6 +14385,11 @@ snapshots:
'@xobotyi/scrollbar-width@1.9.5': {}
'@zarrita/storage@0.2.0':
dependencies:
reference-spec-reader: 0.2.0
unzipit: 2.0.0
'@zxing/text-encoding@0.9.0':
optional: true
@@ -15196,6 +15311,10 @@ snapshots:
dependencies:
d3-array: 3.2.4
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-hierarchy@1.1.9: {}
d3-hierarchy@3.1.2: {}
@@ -15485,6 +15604,8 @@ snapshots:
earcut@2.2.4: {}
earcut@3.2.4: {}
eastasianwidth@0.2.0: {}
ejs@3.1.10:
@@ -15805,6 +15926,8 @@ snapshots:
fflate@0.4.9: {}
fflate@0.8.3: {}
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -15997,6 +16120,17 @@ snapshots:
xml-utils: 1.7.0
zstddec: 0.1.0
geotiff@3.1.0-beta.0:
dependencies:
'@petamoriken/float16': 3.9.3
lerc: 3.0.0
pako: 2.1.0
parse-headers: 2.0.5
quick-lru: 6.1.1
web-worker: 1.5.0
xml-utils: 1.10.2
zstddec: 0.2.0
get-caller-file@2.0.5: {}
get-east-asian-width@1.5.0: {}
@@ -18092,6 +18226,10 @@ snapshots:
dependencies:
boolbase: 1.0.0
numcodecs@0.3.2:
dependencies:
fflate: 0.8.3
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
@@ -18170,6 +18308,15 @@ snapshots:
mapbox-to-css-font: 2.4.5
ol: 7.4.0
ol@10.11.0:
dependencies:
'@types/rbush': 4.0.0
earcut: 3.2.4
geotiff: 3.1.0-beta.0
pbf: 5.1.2
rbush: 4.0.1
zarrita: 0.7.5
ol@7.4.0:
dependencies:
earcut: 2.2.4
@@ -18508,6 +18655,10 @@ snapshots:
ieee754: 1.2.1
resolve-protobuf-schema: 2.1.0
pbf@5.1.2:
dependencies:
resolve-protobuf-schema: 2.1.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -18691,12 +18842,18 @@ snapshots:
quickselect@2.0.0: {}
quickselect@3.0.0: {}
raf-schd@4.0.3: {}
rbush@3.0.1:
dependencies:
quickselect: 2.0.0
rbush@4.0.1:
dependencies:
quickselect: 3.0.0
rc-cascader@3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.28.2
@@ -19444,6 +19601,8 @@ snapshots:
dependencies:
'@eslint-community/regexpp': 4.12.2
reference-spec-reader@0.2.0: {}
refractor@3.6.0:
dependencies:
hastscript: 6.0.0
@@ -20222,6 +20381,19 @@ snapshots:
toggle-selection@1.0.6: {}
topojson-client@3.1.0:
dependencies:
commander: 2.20.3
topojson-server@3.0.1:
dependencies:
commander: 2.20.3
topojson-simplify@3.0.3:
dependencies:
commander: 2.20.3
topojson-client: 3.1.0
tough-cookie@4.1.4:
dependencies:
psl: 1.15.0
@@ -20488,6 +20660,8 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
unzipit@2.0.0: {}
update-browserslist-db@1.3.3(browserslist@4.28.9):
dependencies:
browserslist: 4.28.9
@@ -20829,6 +21003,8 @@ snapshots:
xml-name-validator@4.0.0: {}
xml-utils@1.10.2: {}
xml-utils@1.7.0: {}
xml@1.0.1: {}
@@ -20905,10 +21081,17 @@ snapshots:
yoctocolors@2.1.2: {}
zarrita@0.7.5:
dependencies:
'@zarrita/storage': 0.2.0
numcodecs: 0.3.2
zod@4.3.6: {}
zstddec@0.1.0: {}
zstddec@0.2.0: {}
zustand@5.0.11(@types/react@18.0.26)(immer@11.1.3)(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0)):
optionalDependencies:
'@types/react': 18.0.26

View File

@@ -0,0 +1,208 @@
// Builds the Geo Map outlines assets: Natural Earth 50m admin-0 countries, and
// 10m admin-1 states/provinces for every country with at least two of them.
// Run by hand when bumping Natural Earth or editing regions/aliases; the output is committed.
// Usage: pnpm build:geo-outlines
//
// Nothing is written until every invariant in validate.mjs holds. The assets
// are built in a staging folder and swapped in whole, so a failed run leaves
// the committed ones as they were.
import {
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { gzipSync } from 'node:zlib';
import {
ASSETS_DIR,
COUNTRIES_URL,
SUBDIVISION_LIST_FILE,
SUBDIVISIONS_URL,
} from './config.mjs';
import { buildCountries } from './countries.mjs';
import { buildSubdivisions } from './subdivisions.mjs';
import { validate } from './validate.mjs';
const DOWNLOAD_TIMEOUT_MS = 120_000;
const DOWNLOAD_ATTEMPTS = 3;
const STAGING_DIR = `${ASSETS_DIR}.staging`;
const PREVIOUS_DIR = `${ASSETS_DIR}.previous`;
// Everything in the assets folder is generated; anything else would be lost
// in the swap, so its presence stops the build.
const GENERATED_ENTRIES = new Set([
'countries-50m.json',
'lookups.json',
'subdivisions',
]);
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
const sizeOf = (json) =>
`${kb(Buffer.byteLength(json))} raw / ${kb(gzipSync(json).length)} gzip`;
async function downloadFeatures(url) {
for (let attempt = 1; ; attempt += 1) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.json();
if (
body?.type !== 'FeatureCollection' ||
!Array.isArray(body.features) ||
body.features.length === 0
) {
throw new Error('response is not a non-empty GeoJSON FeatureCollection');
}
return body;
} catch (error) {
if (attempt === DOWNLOAD_ATTEMPTS) {
throw new Error(
`download failed after ${attempt} attempts: ${url}\n ${error.message}`,
);
}
await sleep(attempt * 2_000);
}
}
}
function subdivisionListSource(countries) {
return `// Generated by scripts/geo-outlines/build.mjs; do not edit.
export const SUBDIVISION_COUNTRY_CODES = [
${countries.map((country) => `\t'${country}',`).join('\n')}
] as const;
`;
}
// A run that died mid-swap leaves the assets in the previous folder.
function recoverInterruptedSwap() {
if (!existsSync(ASSETS_DIR) && existsSync(PREVIOUS_DIR)) {
renameSync(PREVIOUS_DIR, ASSETS_DIR);
}
rmSync(STAGING_DIR, { recursive: true, force: true });
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
}
function assertOnlyGeneratedEntries() {
if (!existsSync(ASSETS_DIR)) {
return;
}
const foreign = readdirSync(ASSETS_DIR).filter(
(entry) => !GENERATED_ENTRIES.has(entry),
);
if (foreign.length > 0) {
throw new Error(
`${ASSETS_DIR} holds files this build doesn't generate: ${foreign.join(', ')}`,
);
}
}
/** Writes every output under the staging folder; returns the sizes report. */
function stage(outputs) {
const subdivisionDir = join(STAGING_DIR, 'subdivisions');
mkdirSync(subdivisionDir, { recursive: true });
writeFileSync(join(STAGING_DIR, 'countries-50m.json'), outputs.countriesJson);
writeFileSync(join(STAGING_DIR, 'lookups.json'), outputs.lookupsJson);
const sizes = [];
for (const [country, file] of outputs.subdivisionFiles) {
const json = JSON.stringify(file);
writeFileSync(join(subdivisionDir, `${country}.json`), json);
sizes.push({
country,
shapes: file.objects.subdivisions.geometries.length,
gzip: gzipSync(json).length,
});
}
writeFileSync(`${SUBDIVISION_LIST_FILE}.staging`, outputs.subdivisionList);
return sizes;
}
/** Swaps the staged assets in; puts the previous ones back if that fails. */
function commitStaged() {
const hadAssets = existsSync(ASSETS_DIR);
if (hadAssets) {
renameSync(ASSETS_DIR, PREVIOUS_DIR);
}
try {
renameSync(STAGING_DIR, ASSETS_DIR);
} catch (error) {
if (hadAssets) {
renameSync(PREVIOUS_DIR, ASSETS_DIR);
}
throw error;
}
renameSync(`${SUBDIVISION_LIST_FILE}.staging`, SUBDIVISION_LIST_FILE);
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
}
function cleanUpStaging() {
rmSync(STAGING_DIR, { recursive: true, force: true });
rmSync(`${SUBDIVISION_LIST_FILE}.staging`, { force: true });
}
function report(countries, subdivisions, outputs, sizes) {
const totalGzip = sizes.reduce((sum, { gzip }) => sum + gzip, 0);
return {
countries: {
...countries.report,
sizes: {
'countries-50m.json': sizeOf(outputs.countriesJson),
'lookups.json': sizeOf(outputs.lookupsJson),
},
},
subdivisions: {
...subdivisions.report,
countries: sizes.length,
totalGzip: kb(totalGzip),
largest: [...sizes]
.sort((a, b) => b.gzip - a.gzip)
.slice(0, 10)
.map(
({ country, shapes, gzip }) =>
`${country}: ${shapes} shapes, ${kb(gzip)} gzip`,
),
},
};
}
async function main() {
recoverInterruptedSwap();
assertOnlyGeneratedEntries();
const [countrySource, subdivisionSource] = await Promise.all([
downloadFeatures(COUNTRIES_URL),
downloadFeatures(SUBDIVISIONS_URL),
]);
const countries = buildCountries(countrySource);
const subdivisions = buildSubdivisions(subdivisionSource);
validate(countries, subdivisions);
const outputs = {
countriesJson: JSON.stringify(countries.topology),
lookupsJson: JSON.stringify(countries.lookups),
subdivisionFiles: subdivisions.files,
subdivisionList: subdivisionListSource([...subdivisions.files.keys()]),
};
try {
const sizes = stage(outputs);
commitStaged();
process.stdout.write(
`${JSON.stringify(report(countries, subdivisions, outputs, sizes), null, 2)}\n`,
);
} finally {
cleanUpStaging();
}
}
main().catch((error) => {
process.stderr.write(`Geo outlines build failed: ${error.message}\n`);
process.exitCode = 1;
});

View File

@@ -0,0 +1,176 @@
// Inputs and tuning for the Geo Map outlines build. Tables only; no logic.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const NATURAL_EARTH_BASE =
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson';
export const COUNTRIES_URL = `${NATURAL_EARTH_BASE}/ne_50m_admin_0_countries.geojson`;
export const SUBDIVISIONS_URL = `${NATURAL_EARTH_BASE}/ne_10m_admin_1_states_provinces.geojson`;
const GEO_MAP_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'../../src/lib/visualization/charts/GeoMap',
);
export const ASSETS_DIR = join(GEO_MAP_DIR, 'assets');
export const SUBDIVISION_LIST_FILE = join(
GEO_MAP_DIR,
'geo/subdivisionCountries.ts',
);
export const COUNTRY_QUANTIZATION = 1e5;
// Simplification drops every point whose triangle with its neighbours is
// smaller than this, in square degrees. A fixed size, unlike a share of all
// points, still leaves a small island a few points of its own.
export const COUNTRY_MIN_WEIGHT = 3e-3;
// 10m is street-level detail; a country view needs far less.
export const SUBDIVISION_QUANTIZATION = 1e4;
export const SUBDIVISION_MIN_WEIGHT = 1e-3;
// A shape this many times the minimum weight or smaller could lose every polygon.
export const PROTECTED_SHAPE_FACTOR = 10;
// A country view needs at least this many coded states to be worth drawing.
export const MIN_SUBDIVISIONS = 2;
// ISO 3166-2 codes that changed after Natural Earth v5.1.2, or older spellings
// still in GeoIP data, mapped to the code the shape carries.
export const SUBDIVISION_ALIASES = {
IN: {
'IN-CG': 'IN-CT',
'IN-UK': 'IN-UT',
'IN-TS': 'IN-TG',
'IN-OD': 'IN-OR',
'IN-DD': 'IN-DH',
'IN-DN': 'IN-DH',
},
// Natural Earth carries Metro Manila under its retired code.
PH: { 'PH-00': 'PH-MNL' },
};
// Shapes Natural Earth still splits along retired lines, merged into the codes
// GeoIP reports now; the retired codes become aliases. Northern Ireland's 26
// districts became 11 councils in 2015, each a union of old districts.
// Current code → [current name, retired codes].
export const SUBDIVISION_MERGES = {
GB: {
'GB-ANN': ['Antrim and Newtownabbey', ['GB-ANT', 'GB-NTA']],
'GB-AND': ['Ards and North Down', ['GB-ARD', 'GB-NDN']],
'GB-ABC': [
'Armagh City, Banbridge and Craigavon',
['GB-ARM', 'GB-BNB', 'GB-CGV'],
],
'GB-BFS': ['Belfast', ['GB-BFS']],
'GB-CCG': [
'Causeway Coast and Glens',
['GB-BLY', 'GB-CLR', 'GB-LMV', 'GB-MYL'],
],
'GB-DRS': ['Derry City and Strabane', ['GB-DRY', 'GB-STB']],
'GB-FMO': ['Fermanagh and Omagh', ['GB-FER', 'GB-OMH']],
'GB-LBC': ['Lisburn and Castlereagh', ['GB-LSB', 'GB-CSR']],
'GB-MEA': ['Mid and East Antrim', ['GB-BLA', 'GB-CKF', 'GB-LRN']],
'GB-MUL': ['Mid Ulster', ['GB-CKT', 'GB-DGN', 'GB-MFT']],
'GB-NMD': ['Newry, Mourne and Down', ['GB-NYM', 'GB-DOW']],
},
};
// Natural Earth puts these islands in "Seven seas (open ocean)".
export const OPEN_OCEAN_REGION = {
Africa: 'africa',
Asia: 'asia',
Americas: 'south_america',
};
export const CONTINENT_REGION = {
Africa: 'africa',
Asia: 'asia',
Europe: 'europe',
'North America': 'north_america',
'South America': 'south_america',
Oceania: 'oceania',
};
// Transcontinental countries belong to every region they span.
export const EXTRA_REGIONS = {
RU: ['asia'],
TR: ['europe'],
KZ: ['europe'],
GE: ['europe'],
AM: ['europe'],
AZ: ['europe'],
CY: ['europe'],
EG: ['asia'],
};
// ISO codes GeoIP emits for places Natural Earth draws inside another country.
export const COUNTRY_ALIASES = {
GF: 'FR',
GP: 'FR',
MQ: 'FR',
RE: 'FR',
YT: 'FR',
BQ: 'NL',
SJ: 'NO',
BV: 'NO',
CX: 'AU',
CC: 'AU',
HM: 'AU',
UM: 'US',
TK: 'NZ',
AC: 'SH',
TA: 'SH',
DG: 'IO',
CP: 'FR',
FX: 'FR',
EA: 'ES',
IC: 'ES',
CQ: 'GB',
GI: 'GB',
// Retired or reserved codes still seen in older data.
UK: 'GB',
SU: 'RU',
YU: 'RS',
CS: 'RS',
TP: 'TL',
BU: 'MM',
ZR: 'CD',
DD: 'DE',
VD: 'VN',
YD: 'YE',
RH: 'ZW',
NH: 'VU',
HV: 'BF',
DY: 'BJ',
AN: 'CW',
};
export const COUNTRY_NAME_ALIASES = {
usa: 'US',
'united states of america': 'US',
america: 'US',
uk: 'GB',
'great britain': 'GB',
britain: 'GB',
england: 'GB',
russia: 'RU',
'south korea': 'KR',
'north korea': 'KP',
czechia: 'CZ',
'czech republic': 'CZ',
'ivory coast': 'CI',
turkiye: 'TR',
türkiye: 'TR',
burma: 'MM',
'cape verde': 'CV',
swaziland: 'SZ',
macedonia: 'MK',
'east timor': 'TL',
vatican: 'VA',
};
// Alpha-2 codes Intl names that aren't countries, so none needs a shape.
export const NON_COUNTRY_CODES = new Set([
'EU',
'EZ',
'UN',
'ZZ',
'QO',
'XA',
'XB',
'AQ',
]);

View File

@@ -0,0 +1,185 @@
// Natural Earth admin-0 → the countries topology and the lookup tables.
import { geoArea, geoBounds } from 'd3-geo';
import { feature } from 'topojson-client';
import {
CONTINENT_REGION,
COUNTRY_ALIASES,
COUNTRY_MIN_WEIGHT,
COUNTRY_NAME_ALIASES,
COUNTRY_QUANTIZATION,
EXTRA_REGIONS,
NON_COUNTRY_CODES,
OPEN_OCEAN_REGION,
} from './config.mjs';
import {
polygonsOf,
round,
roundBounds,
simplifyAndQuantize,
} from './geometry.mjs';
export const isCountryCode = (value) => /^[A-Z]{2}$/.test(value ?? '');
function regionsOf(props, iso2) {
const base = props.CONTINENT.startsWith('Seven seas')
? OPEN_OCEAN_REGION[props.REGION_UN]
: CONTINENT_REGION[props.CONTINENT];
return [...new Set([base, ...(EXTRA_REGIONS[iso2] ?? [])].filter(Boolean))];
}
// Group features by code so a country's overseas parts (Australia's Indian
// Ocean territories) become one shape that one data row colours.
function groupByCode(sourceFeatures, report) {
const groups = new Map();
for (const f of sourceFeatures) {
const p = f.properties;
if (p.CONTINENT === 'Antarctica') {
report.dropped.push(p.NAME);
continue;
}
const iso2 = isCountryCode(p.ISO_A2_EH) ? p.ISO_A2_EH : null;
const key = iso2 ?? `uncoded:${p.NAME}`;
if (!iso2) {
report.uncoded.push(p.NAME);
}
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(f);
}
return groups;
}
function toCountryFeature(key, members, report) {
// The sovereign part carries the name, label point and region.
const primary =
members.find((f) => f.properties.ISO_A2 === f.properties.ISO_A2_EH) ??
members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
const p = primary.properties;
const iso2 = key.startsWith('uncoded:') ? null : key;
if (members.length > 1) {
report.merged.push(
`${iso2}: ${members.map((f) => f.properties.NAME).join(' + ')}`,
);
}
const polygons = members.flatMap((f) => polygonsOf(f.geometry));
const geometry = { type: 'MultiPolygon', coordinates: polygons };
const mainland = polygons
.map((coordinates) => ({ type: 'Polygon', coordinates }))
.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
return {
type: 'Feature',
id: iso2 ?? p.NAME,
properties: {
code: iso2,
iso3: /^[A-Z]{3}$/.test(p.ISO_A3_EH) ? p.ISO_A3_EH : null,
name: p.NAME,
label: [round(p.LABEL_X), round(p.LABEL_Y)],
mainland: roundBounds(geoBounds(mainland)),
bounds: roundBounds(geoBounds(geometry)),
regions: regionsOf(p, iso2 ?? ''),
},
geometry,
};
}
/** Alpha-3 codes and English names → alpha-2, for normalising data. */
function buildLookups(sourceFeatures) {
const alpha3 = {};
const names = { ...COUNTRY_NAME_ALIASES };
for (const { properties: p } of sourceFeatures) {
if (!isCountryCode(p.ISO_A2_EH) || p.CONTINENT === 'Antarctica') {
continue;
}
if (/^[A-Z]{3}$/.test(p.ISO_A3_EH)) {
alpha3[p.ISO_A3_EH] = p.ISO_A2_EH;
}
for (const name of [
p.NAME,
p.NAME_LONG,
p.ADMIN,
p.NAME_EN,
p.BRK_NAME,
p.NAME_SORT,
p.NAME_CIAWF,
]) {
if (name) {
names[name.toLowerCase()] ??= p.ISO_A2_EH;
}
}
}
return { alpha3, names, aliases: COUNTRY_ALIASES };
}
/** ISO alpha-2 codes Intl knows that neither a shape nor an alias covers. */
function findUncoveredCodes(drawn) {
const display = new Intl.DisplayNames(['en'], {
type: 'region',
fallback: 'none',
});
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const uncovered = [];
for (const a of letters) {
for (const b of letters) {
const code = a + b;
const name = display.of(code);
if (
name &&
!NON_COUNTRY_CODES.has(code) &&
!drawn.has(code) &&
!COUNTRY_ALIASES[code]
) {
uncovered.push(`${code} ${name}`);
}
}
}
return uncovered;
}
/** Natural Earth admin-0 GeoJSON → `{ topology, lookups, report }`. */
export function buildCountries(source) {
const report = { dropped: [], uncoded: [], merged: [] };
const features = [...groupByCode(source.features, report)]
.map(([key, members]) => toCountryFeature(key, members, report))
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
'countries',
features,
COUNTRY_MIN_WEIGHT,
COUNTRY_QUANTIZATION,
);
const drawn = new Set(features.map((f) => f.properties.code).filter(Boolean));
// Lets data be checked against the drawn codes without decoding the outlines.
const lookups = { codes: [...drawn].sort(), ...buildLookups(source.features) };
const regionCounts = {};
for (const f of features) {
for (const region of f.properties.regions) {
regionCounts[region] = (regionCounts[region] ?? 0) + 1;
}
}
return {
topology: topo,
lookups,
report: {
features: features.length,
decodedFeatures: feature(topo, topo.objects.countries).features.length,
droppedFlippedIslands: flipped,
droppedCollapsedPolygons: collapsed,
emptied: emptied.join(' '),
regionCounts,
...report,
lookupCounts: {
alpha3: Object.keys(lookups.alpha3).length,
names: Object.keys(lookups.names).length,
aliases: Object.keys(lookups.aliases).length,
},
uncoveredIsoCodes: findUncoveredCodes(drawn),
},
};
}

View File

@@ -0,0 +1,114 @@
// Geometry helpers and the simplification shared by countries and states.
import { geoArea } from 'd3-geo';
import { topology } from 'topojson-server';
import { presimplify, simplify } from 'topojson-simplify';
import { feature, quantize } from 'topojson-client';
import { PROTECTED_SHAPE_FACTOR } from './config.mjs';
export const round = (n) => Math.round(n * 1e3) / 1e3;
export const roundBounds = (bounds) =>
bounds.map(([x, y]) => [round(x), round(y)]);
export function polygonsOf(geometry) {
if (geometry.type === 'Polygon') {
return [geometry.coordinates];
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates;
}
return [];
}
/** Area of a lon/lat ring in square degrees, the unit of the simplify weights. */
function planarRingArea(ring) {
let twice = 0;
ring.forEach(([x, y], i) => {
const [nx, ny] = ring[(i + 1) % ring.length];
twice += x * ny - nx * y;
});
return Math.abs(twice) / 2;
}
// Weights every point of a small shape's largest outline as Infinity, which
// simplify never removes, so a city-state or city-sized province (Monaco, a
// London borough) still has an outline to colour. Neighbours share those arcs,
// so their common border stays in step.
function protectSmallShapes(topo, objectName, features, minWeight) {
topo.objects[objectName].geometries.forEach((geometry, i) => {
const polygons = polygonsOf(features[i].geometry);
const areas = polygons.map(([outer]) => planarRingArea(outer));
const total = areas.reduce((sum, area) => sum + area, 0);
if (polygons.length === 0 || total > minWeight * PROTECTED_SHAPE_FACTOR) {
return;
}
const largest = areas.indexOf(Math.max(...areas));
const rings =
geometry.type === 'Polygon' ? geometry.arcs : geometry.arcs[largest];
rings[0].forEach((index) => {
topo.arcs[index < 0 ? ~index : index].forEach((point) => {
point[2] = Infinity;
});
});
});
}
const isCollapsed = ([outer]) =>
new Set(outer.map((point) => point.join())).size < 3;
// Simplifying can collapse a tiny island until its ring runs the other way; d3
// then reads it as "the whole globe except this island" and floods the map.
const isInverted = (coordinates) =>
geoArea({ type: 'Polygon', coordinates }) > 2 * Math.PI;
/**
* One feature collection to a simplified, quantized topology under
* `objectName`. Polygons that collapse to a sliver or flip inside out are
* dropped and the topology rebuilt without them.
*/
export function simplifyAndQuantize(
objectName,
features,
minWeight,
quantization,
) {
let topo = presimplify(
topology({ [objectName]: { type: 'FeatureCollection', features } }),
);
protectSmallShapes(topo, objectName, features, minWeight);
topo = simplify(topo, minWeight);
// presimplify stores each point's weight as a third coordinate and simplify
// keeps it, so it is dropped before quantizing (which would carry it through).
topo = { ...topo, arcs: topo.arcs.map((arc) => arc.map(([x, y]) => [x, y])) };
topo = quantize(topo, quantization);
let flipped = 0;
let collapsed = 0;
const cleaned = feature(topo, topo.objects[objectName]).features.map((f) => {
const kept = polygonsOf(f.geometry).filter((coordinates) => {
if (isCollapsed(coordinates)) {
collapsed += 1;
return false;
}
if (isInverted(coordinates)) {
flipped += 1;
return false;
}
return true;
});
return { ...f, geometry: { type: 'MultiPolygon', coordinates: kept } };
});
const emptied = cleaned
.filter((f) => f.geometry.coordinates.length === 0)
.map((f) => f.properties.code ?? f.properties.name);
if (flipped > 0 || collapsed > 0) {
topo = quantize(
topology({
[objectName]: { type: 'FeatureCollection', features: cleaned },
}),
quantization,
);
}
return { topo, flipped, collapsed, emptied };
}

View File

@@ -0,0 +1,188 @@
// Natural Earth admin-1 → one states/provinces topology per country.
import { geoArea } from 'd3-geo';
import { topology } from 'topojson-server';
import { merge } from 'topojson-client';
import {
MIN_SUBDIVISIONS,
SUBDIVISION_ALIASES,
SUBDIVISION_MERGES,
SUBDIVISION_MIN_WEIGHT,
SUBDIVISION_QUANTIZATION,
} from './config.mjs';
import { isCountryCode } from './countries.mjs';
import { round, simplifyAndQuantize } from './geometry.mjs';
const SUBDIVISION_CODE = /^[A-Z]{2}-[A-Z0-9]{1,3}$/;
/** Retired code → `[current code, current name]`, from `SUBDIVISION_MERGES`. */
const RECODES = new Map(
Object.values(SUBDIVISION_MERGES).flatMap((merges) =>
Object.entries(merges).flatMap(([code, [name, retired]]) =>
retired.map((old) => [old, [code, name]]),
),
),
);
/** A country's aliases: the hand-written ones, then each merge's retired codes. */
function aliasesOf(country) {
const aliases = { ...SUBDIVISION_ALIASES[country] };
for (const [code, [, retired]] of Object.entries(
SUBDIVISION_MERGES[country] ?? {},
)) {
for (const old of retired) {
if (old !== code) {
aliases[old] = code;
}
}
}
return aliases;
}
// Shapes without an ISO 3166-2 code can't be matched to data, and a code under
// another country's prefix (Puerto Rico's `US-PR`) belongs to that country's
// file. Retired codes are rewritten to the current ones here, so the merge
// below dissolves them.
function groupByCountry(sourceFeatures) {
const byCountry = new Map();
const uncoded = {};
const recoded = new Set();
for (const f of sourceFeatures) {
const country = f.properties.iso_a2;
if (!isCountryCode(country)) {
continue;
}
const code = f.properties.iso_3166_2;
if (!SUBDIVISION_CODE.test(code ?? '') || !code.startsWith(`${country}-`)) {
uncoded[country] = (uncoded[country] ?? 0) + 1;
continue;
}
if (!byCountry.has(country)) {
byCountry.set(country, []);
}
const recode = RECODES.get(code);
if (recode) {
recoded.add(code);
}
byCountry.get(country).push(
recode
? {
...f,
properties: {
...f.properties,
iso_3166_2: recode[0],
name: recode[1],
},
}
: f,
);
}
return { byCountry, uncoded, recoded };
}
// Natural Earth splits some codes across shapes (Dublin's four councils are all
// `IE-D`). Merging them through a topology drops the borders between the parts,
// so one code is one shape with one outline.
function mergeByCode(features) {
const byCode = new Map();
for (const f of features) {
const code = f.properties.iso_3166_2;
if (!byCode.has(code)) {
byCode.set(code, []);
}
byCode.get(code).push(f);
}
const topo = topology({ parts: { type: 'FeatureCollection', features } });
const { geometries } = topo.objects.parts;
let merged = 0;
const shapes = [...byCode].map(([code, members]) => {
// The largest part carries the name and label point.
const primary = members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
let geometry = primary.geometry;
if (members.length > 1) {
merged += members.length - 1;
geometry = merge(
topo,
geometries.filter((_, i) => features[i].properties.iso_3166_2 === code),
);
}
return {
type: 'Feature',
id: code,
properties: {
code,
name: primary.properties.name,
label: [
round(primary.properties.longitude),
round(primary.properties.latitude),
],
},
geometry,
};
});
return { shapes: shapes.sort((a, b) => a.id.localeCompare(b.id)), merged };
}
/** Lower-cased English names → code, for data that names a state. */
function namesOf(members) {
const names = {};
for (const { properties: p } of members) {
for (const name of [p.name, p.name_en, p.gn_name, p.woe_name]) {
if (name) {
names[name.toLowerCase()] ??= p.iso_3166_2;
}
}
}
return names;
}
/**
* Natural Earth admin-1 GeoJSON → `{ files, report }`, where `files` maps each
* country with enough coded states to its outlines file, in code order.
*/
export function buildSubdivisions(source) {
const { byCountry, uncoded, recoded } = groupByCountry(source.features);
const files = new Map();
const report = {
skippedUnderMinimum: [],
mergedParts: {},
uncodedShapes: uncoded,
droppedFlippedIslands: {},
droppedCollapsedPolygons: 0,
emptiedShapes: [],
// Merge entries whose retired code Natural Earth no longer has.
unmatchedRetiredCodes: [...RECODES.keys()].filter(
(code) => !recoded.has(code),
),
};
const countries = [...byCountry.keys()].sort((a, b) => a.localeCompare(b));
for (const country of countries) {
const members = byCountry.get(country);
const { shapes, merged } = mergeByCode(members);
if (shapes.length < MIN_SUBDIVISIONS) {
report.skippedUnderMinimum.push(country);
continue;
}
if (merged) {
report.mergedParts[country] = merged;
}
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
'subdivisions',
shapes,
SUBDIVISION_MIN_WEIGHT,
SUBDIVISION_QUANTIZATION,
);
if (flipped) {
report.droppedFlippedIslands[country] = flipped;
}
report.droppedCollapsedPolygons += collapsed;
report.emptiedShapes.push(...emptied);
files.set(country, {
...topo,
aliases: aliasesOf(country),
names: namesOf(members),
});
}
return { files, report };
}

View File

@@ -0,0 +1,110 @@
// Invariants the chart relies on, checked before anything is written. A
// failure names every problem; the committed assets are left untouched.
import { feature } from 'topojson-client';
import { MIN_SUBDIVISIONS } from './config.mjs';
// Natural Earth v5.1.2 yields 239 countries and 198 countries with states. A
// drop below these means a changed or truncated download, not real geography.
const MIN_COUNTRIES = 230;
const MIN_SUBDIVISION_COUNTRIES = 190;
function duplicatesOf(values) {
const seen = new Set();
return values.filter((value) => seen.has(value) || !seen.add(value));
}
function checkCountries({ topology, lookups, report }, problems) {
const shapes = feature(topology, topology.objects.countries).features;
const codes = shapes.map((shape) => shape.properties.code).filter(Boolean);
const drawn = new Set(codes);
if (shapes.length < MIN_COUNTRIES) {
problems.push(
`only ${shapes.length} countries (expected ≥ ${MIN_COUNTRIES})`,
);
}
if (report.emptied) {
problems.push(`countries left with no outline: ${report.emptied}`);
}
for (const code of duplicatesOf(codes)) {
problems.push(`country code ${code} is drawn twice`);
}
if (lookups.codes.join() !== [...drawn].sort().join()) {
problems.push('lookups.codes differs from the drawn country codes');
}
for (const [alias, target] of Object.entries(lookups.aliases)) {
if (!drawn.has(target)) {
problems.push(`country alias ${alias} → ${target}, which has no shape`);
}
}
for (const [alpha3, target] of Object.entries(lookups.alpha3)) {
if (!drawn.has(target)) {
problems.push(`alpha-3 ${alpha3} → ${target}, which has no shape`);
}
}
}
function checkSubdivisionFile(country, file, problems) {
const codes = file.objects.subdivisions.geometries.map(
(geometry) => geometry.properties.code,
);
const known = new Set(codes);
if (codes.length < MIN_SUBDIVISIONS) {
problems.push(
`${country}: ${codes.length} states (expected ≥ ${MIN_SUBDIVISIONS})`,
);
}
for (const code of duplicatesOf(codes)) {
problems.push(`${country}: state code ${code} is drawn twice`);
}
for (const code of codes) {
if (!code?.startsWith(`${country}-`)) {
problems.push(`${country}: state code ${code} is outside the country`);
}
}
for (const [alias, target] of Object.entries(file.aliases)) {
if (!known.has(target)) {
problems.push(`${country}: alias ${alias} → ${target}, which has no shape`);
}
}
for (const [name, code] of Object.entries(file.names)) {
if (!known.has(code)) {
problems.push(`${country}: name "${name}" → ${code}, which has no shape`);
}
}
}
function checkSubdivisions({ files, report }, problems) {
if (files.size < MIN_SUBDIVISION_COUNTRIES) {
problems.push(
`only ${files.size} countries with states (expected ≥ ${MIN_SUBDIVISION_COUNTRIES})`,
);
}
if (report.emptiedShapes.length > 0) {
problems.push(
`states left with no outline: ${report.emptiedShapes.join(' ')}`,
);
}
if (report.unmatchedRetiredCodes.length > 0) {
problems.push(
`merge codes not found in Natural Earth: ${report.unmatchedRetiredCodes.join(' ')}`,
);
}
for (const [country, file] of files) {
checkSubdivisionFile(country, file, problems);
}
}
/** Throws one error listing every broken invariant. */
export function validate(countries, subdivisions) {
const problems = [];
checkCountries(countries, problems);
checkSubdivisions(subdivisions, problems);
if (problems.length > 0) {
throw new Error(
`${problems.length} problem(s) in the generated outlines:\n- ${problems.join('\n- ')}`,
);
}
}

View File

@@ -9,14 +9,12 @@ describe('ErrorResponseHandler', () => {
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
{
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
},
);
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
});
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();

View File

@@ -18,10 +18,8 @@ import type {
} from 'react-query';
import type {
IndexMaterializedPaths200,
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPostableIndexMaterializedPathsDTO,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -232,87 +230,3 @@ export const usePromotePaths = <
> => {
return useMutation(getPromotePathsMutationOptions(options));
};
/**
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
* @summary Index materialized paths
*/
export const indexMaterializedPaths = (
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<IndexMaterializedPaths200>({
url: `/api/v1/promoted_paths/materialized`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPostableIndexMaterializedPathsDTO,
signal,
});
};
export const getIndexMaterializedPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
const mutationKey = ['indexMaterializedPaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
> = (props) => {
const { data } = props ?? {};
return indexMaterializedPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type IndexMaterializedPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof indexMaterializedPaths>>
>;
export type IndexMaterializedPathsMutationBody =
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
| undefined;
export type IndexMaterializedPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Index materialized paths
*/
export const useIndexMaterializedPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
return useMutation(getIndexMaterializedPathsMutationOptions(options));
};

View File

@@ -18,8 +18,6 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -965,85 +963,6 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -4938,6 +4938,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5695,6 +5701,166 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
export interface DashboardtypesTopListAppearanceDTO {
/**
* @type string
* @description Hex color of every bar; a matching background threshold overrides it.
*/
barColor: string;
/**
* @type boolean
* @description Numbers each row by its position in the list.
*/
showRank?: boolean;
/**
* @type boolean
* @description Shows each row's share of the listed rows' total.
*/
showShare?: boolean;
}
export interface DashboardtypesTopListPanelSpecDTO {
appearance?: DashboardtypesTopListAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesComparisonThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO {
/**
* @enum signoz/TopListPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind;
spec: DashboardtypesTopListPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5705,7 +5871,9 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6645,6 +6813,8 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
/**
* @nullable
@@ -9878,28 +10048,6 @@ export interface PromotetypesPromotePathDTO {
signal: string;
}
export interface PromotetypesIndexMaterializedPathsResultDTO {
/**
* @type array,null
*/
indexed: PromotetypesPromotePathDTO[] | null;
/**
* @type array,null
*/
skipped: PromotetypesPromotePathDTO[] | null;
}
export interface PromotetypesPostableIndexMaterializedPathsDTO {
/**
* @type boolean
*/
dryRun?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
@@ -13272,14 +13420,6 @@ export type ListPromotedPaths200 = {
status: string;
};
export type IndexMaterializedPaths200 = {
data: PromotetypesIndexMaterializedPathsResultDTO;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array
@@ -14612,17 +14752,6 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

View File

@@ -61,11 +61,7 @@ describe('convertV5ResponseToLegacy', () => {
],
values: [
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
{
timestamp: 2000,
value: 12,
partial: true,
} as unknown as TimeSeriesValue,
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
],
} as unknown as TimeSeries,
],
@@ -108,7 +104,7 @@ describe('convertV5ResponseToLegacy', () => {
labels: { 'service.name': 'adservice' },
values: [
{ timestamp: 1000, value: '10' },
{ timestamp: 2000, value: '12', partial: true },
{ timestamp: 2000, value: '12' },
],
metaData: expect.objectContaining({
alias: '__result_0',

View File

@@ -125,7 +125,6 @@ function convertTimeSeriesData(
values: series.values.map((value: any) => ({
timestamp: value.timestamp,
value: String(value.value),
...(value.partial ? { partial: true } : {}),
})),
metaData: {
alias,

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,8 +51,10 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
case PANEL_TYPES.TOP_LIST:
return 'scalar';
case PANEL_TYPES.TRACE:
return 'trace';
@@ -278,8 +280,10 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST);
if (queryData.dataSource === DataSource.METRICS) {
return [

View File

@@ -1,3 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0E9AA7" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +1,3 @@
<svg role="img" viewBox="-2.625 3 28 28" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#37814A" d="M2.303 0A2.298 2.298 0 0 0 0 2.303v19.394A2.298 2.298 0 0 0 2.303 24h19.394A2.298 2.298 0 0 0 24 21.697V2.303A2.298 2.298 0 0 0 21.697 0zm8.177 3.072c4.098 0 7.028 1.438 7.68 1.764l-1.194 2.55c-2.442-1.057-4.993-1.41-5.672-1.41-1.574 0-2.17.922-2.17 1.763v8.494c0 .869.596 1.791 2.17 1.791.679 0 3.23-.38 5.672-1.41l1.194 2.496c-.435.271-3.637 1.818-7.68 1.818-1.112 0-4.64-.244-4.64-4.64V7.713c0-4.397 3.528-4.64 4.64-4.64z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="图层_1" x="0" y="0" version="1.1" viewBox="0 0 519.27 214.14"><style>.st2{fill:#a9cc54}</style><path d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1L493.54 5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01" style="fill:#b6de64"/><defs><path id="SVGID_1_" d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1V5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><g style="clip-path:url(#SVGID_2_)"><path d="M-23.16 55.58H428.5v51.1H-23.16zM-23.16 157.59h530.98v72.35H-23.16z" class="st2"/></g><path d="M493.54 184.23c-43.94 0-79.56-33.62-79.56-77.56s35.62-76.56 79.56-76.56" style="fill:none;stroke:#ddf4a4;stroke-width:49;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="lucide/github">
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#7A8291" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#C0C1C3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#A68400" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#7C4DD6" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#430098" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 958 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#7A8291"></path></svg>
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LiveKit</title><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#fff"></path></svg>

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>
<svg width='234' height='42' viewBox='0 0 234 42' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M18 30H6V18H18V30Z' fill='#CFCECD'/><path d='M18 12H6V30H18V12ZM24 36H0V6H24V36Z' fill='#656363'/><path d='M48 30H36V18H48V30Z' fill='#CFCECD'/><path d='M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z' fill='#656363'/><path d='M84 24V30H66V24H84Z' fill='#CFCECD'/><path d='M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z' fill='#656363'/><path d='M108 36H96V18H108V36Z' fill='#CFCECD'/><path d='M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z' fill='#656363'/><path d='M144 30H126V18H144V30Z' fill='#CFCECD'/><path d='M144 12H126V30H144V36H120V6H144V12Z' fill='#211E1E'/><path d='M168 30H156V18H168V30Z' fill='#CFCECD'/><path d='M168 12H156V30H168V12ZM174 36H150V6H174V36Z' fill='#211E1E'/><path d='M198 30H186V18H198V30Z' fill='#CFCECD'/><path d='M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z' fill='#211E1E'/><path d='M234 24V30H216V24H234Z' fill='#CFCECD'/><path d='M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z' fill='#211E1E'/></svg>

Before

Width:  |  Height:  |  Size: 137 B

After

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -1 +1 @@
<svg role="img" viewBox="89.7 190 220 220" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" viewBox="0 0 800 600"><path d="M364.7 246.1h16.9L363.2 300h-16.9l-18.5-53.9h17.1l10.2 33h.2zM436 300h-39.4v-53.9h39.2v13.6h-23.6v7.1h20.9V279h-20.9v7.4H436zm41.6-54.8c11.3 0 19.6 5.8 22.1 15.1l-14.7 6.4c-.7-4.2-2.9-7.4-7.6-7.4-5.3 0-9.3 4.4-9.3 14s3.8 14.2 9.3 14.2c4.5 0 6.9-3.4 7.6-7.6l14.7 6.4c-2.5 9.3-10.9 15.1-22.1 15.1-16.2 0-26.1-10.7-26.1-28s9.9-28.2 26.1-28.2m50.3 54.8v-40.3h-16.2v-13.6h48.5v13.6H544V300zm97.7-26.9c0 17.2-10 28-26.1 28-16 0-26.1-10.7-26.1-28s10.2-28 26.1-28c16.1.1 26.1 10.8 26.1 28m-35.4 0c0 8.7 3.3 14.2 9.4 14.2s9.4-5.3 9.4-14.2c0-8.7-3.3-14.2-9.4-14.2s-9.4 5.3-9.4 14.2m84.4 26.9c-1.5-1.8-2-3.4-2.5-6.4l-1.1-6c-.7-3.8-2.5-4.9-5.8-4.9h-6.4V300h-16v-53.9h25.6c12.2 0 20.7 4.7 20.7 16.5 0 7.8-5.3 12.3-11.1 13.4v.2c4.7.7 7.4 3.6 8.5 8.5l2 8.5c.7 2.9 1.6 4.7 3.4 6.7.1.1-17.3.1-17.3.1m-8.2-40.7h-7.8V270h7.8c3.8 0 5.8-1.6 5.8-5.4 0-3.5-1.8-5.3-5.8-5.3m-321.5 81.2c3.3.4 5.6 2.4 5.6 6 0 4.4-2.7 7.3-8.7 7.3h-9.3v-26.1h8.9c5.1 0 8.2 2.4 8.2 6.9 0 3.1-2 5.3-4.7 5.9m-9.1-1.1h5.4c3.3 0 5.1-1.5 5.1-4.5s-1.8-4.5-5.1-4.5h-5.4zm0 11.6h5.8c3.6 0 5.4-1.6 5.4-4.5s-2-4.5-5.4-4.5h-5.8zm28-10.7 7.1-12.7h3.4l-8.9 15.8v10.2h-3.3v-10.2l-9.1-15.8h3.6zm26.7-12.7h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.2 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm36.2-4.3H419l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-10-2.9h8.9l-4.4-12.5zm24.1 10.1v-23.2h-8.7v-2.9h20.9v2.9h-8.7v23.2zm28.3-7.2h-10.9l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-9.8-2.9h8.9l-4.4-12.5zm20.7-16h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.3 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm44.1-10.1c0 8.4-4.5 13.4-11.3 13.4S508 349 508 340.7c0-8.4 4.5-13.4 11.3-13.4s11.3 5 11.3 13.4m-19 0c0 6.4 2.7 10.7 7.8 10.7s7.8-4.4 7.8-10.7c0-6.4-2.7-10.7-7.8-10.7s-7.8 4.3-7.8 10.7m45-.2v13.1H554l-.5-3.8c-.9 2.7-3.8 4.4-7.3 4.4-6.5 0-11.1-4.9-11.1-13.4 0-8.7 4.9-13.6 11.4-13.6 4.7 0 8 2 9.4 6l-2.9 1.6c-.9-3.1-3.4-4.9-6.7-4.9-5.1 0-8 4-8 10.7 0 6.4 2.5 10.7 8 10.7 3.4 0 6.7-1.8 6.7-6.5V343h-6.4v-2.7h9.8z"/><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -146,10 +146,21 @@ function QueryAddOns({
[resolvedFields],
);
// A panel's default limit applies only to a lone query: per-query limits can keep
// different groups and break a formula's join.
const isLoneQuery =
(currentQuery.builder?.queryData?.length ?? 1) +
(currentQuery.builder?.queryFormulas?.length ?? 0) +
(currentQuery.builder?.queryTraceOperator?.length ?? 0) <=
1;
const placeholderOf = useCallback(
(key: InputAddOnField): string =>
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields],
(key === QueryBuilderField.Limit && !isLoneQuery
? undefined
: resolvedFields.get(key)?.placeholder) ??
ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields, isLoneQuery],
);
const isDisabled = useCallback(

View File

@@ -47,7 +47,10 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST)
) {
return { hidden: true, disabled: false, pinned: false };
}
@@ -69,7 +72,9 @@ function QueryAggregationOptions({
onChange={onChange}
queryData={queryData}
maxAggregations={
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST
? 1
: undefined
}

View File

@@ -115,8 +115,10 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST),
[dataSource, panelType],
);

View File

@@ -15,6 +15,9 @@ import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
unit: undefined,
};
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): {
@@ -27,10 +30,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
currentQuery: typeof mockCurrentQuery;
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
currentQuery: mockCurrentQuery,
}),
}));
@@ -222,6 +225,59 @@ describe('QueryAddOns', () => {
);
});
describe('a pinned limit with a default', () => {
const renderPinnedLimit = (): void => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TOP_LIST}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'pinned',
placeholder: 'Default 10',
},
}}
isForTraceOperator={false}
/>,
);
};
afterEach(() => {
mockCurrentQuery = { unit: undefined };
});
it('is open with its default as the placeholder', () => {
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10',
);
});
it('drops the default once a formula joins the query', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [baseQuery()],
queryFormulas: [{ expression: 'A * 2' }],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -197,11 +197,6 @@ describe('QuickFiltersLayout', () => {
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
'aria-busy',
),
);
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
@@ -232,11 +227,6 @@ describe('QuickFiltersLayout', () => {
'data-source',
SavedviewtypesSourceDTO.traces,
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
'aria-busy',
),
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',

View File

@@ -34,8 +34,10 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.TOP_LIST]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,9 +337,11 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TOP_LIST = 'top_list',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -560,7 +560,7 @@ const onboardingConfigWithLinks = [
key: 'springboot',
label: 'Spring Boot',
imgUrl: springbootUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -570,25 +570,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
],
},
@@ -597,7 +597,7 @@ const onboardingConfigWithLinks = [
key: 'tomcat',
label: 'Tomcat',
imgUrl: tomcatUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -607,25 +607,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
],
},
@@ -634,7 +634,7 @@ const onboardingConfigWithLinks = [
key: 'jboss',
label: 'JBoss/WildFly',
imgUrl: jbossUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -644,25 +644,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
],
},
@@ -671,7 +671,7 @@ const onboardingConfigWithLinks = [
key: 'opentelemetry-quarkus',
label: 'Quarkus',
imgUrl: quarkusUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -681,25 +681,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
],
},
@@ -708,7 +708,7 @@ const onboardingConfigWithLinks = [
key: 'Others',
label: 'Others',
imgUrl: javaOthersUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -718,25 +718,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
],
},
@@ -872,7 +872,7 @@ const onboardingConfigWithLinks = [
key: 'nodejs-nestjs',
label: 'NodeJs/NestJS',
imgUrl: nodejsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -882,25 +882,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
],
},
@@ -909,7 +909,7 @@ const onboardingConfigWithLinks = [
key: 'nextjs',
label: 'NextJS',
imgUrl: nextjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -919,25 +919,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
],
},
@@ -946,7 +946,7 @@ const onboardingConfigWithLinks = [
key: 'nuxtjs',
label: 'NuxtJS',
imgUrl: nuxtjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -956,25 +956,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
],
},
@@ -983,7 +983,7 @@ const onboardingConfigWithLinks = [
key: 'react-native',
label: 'React Native',
imgUrl: reactjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-react-native/',
link: '/docs/instrumentation/opentelemetry-react-native/',
},
],
},
@@ -1298,7 +1298,7 @@ const onboardingConfigWithLinks = [
'resque',
],
id: 'ruby',
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -1308,25 +1308,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
],
},
@@ -2292,7 +2292,7 @@ const onboardingConfigWithLinks = [
'python structured logging',
'python-logs-auto-instrumentation',
],
link: '/docs/logs-management/send-logs/python-logs/',
link: '/docs/userguide/python-logs-auto-instrumentation/',
},
{
dataSource: 'otel-java-sdk',
@@ -2323,7 +2323,7 @@ const onboardingConfigWithLinks = [
'Logback',
'log4j',
],
link: '/docs/logs-management/send-logs/java-logs/',
link: '/docs/userguide/collecting_application_logs_otel_sdk_java/',
},
{
dataSource: 'nodejs-logs',
@@ -2463,7 +2463,7 @@ const onboardingConfigWithLinks = [
'linux',
],
imgUrl: hostmetricsUrl,
link: '/docs/infrastructure-monitoring/user-guides/hostmetrics/',
link: '/docs/userguide/hostmetrics/',
},
{
dataSource: 'k8s-metrics',
@@ -2495,7 +2495,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
imgUrl: kubernetesUrl,
link: '/docs/infrastructure-monitoring/user-guides/k8s-metrics/',
link: '/docs/userguide/k8s-metrics/',
},
{
dataSource: 'docker-container-metrics',
@@ -2583,7 +2583,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-manual',
label: 'Manual Setup',
imgUrl: ec2Url,
link: '/docs/opentelemetry-collection-agents/vm/install/',
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
},
],
},
@@ -2772,7 +2772,7 @@ const onboardingConfigWithLinks = [
'vpc-logs',
],
imgUrl: vpcUrl,
link: '/docs/aws-monitoring/vpc/',
link: '/docs/aws-monitoring/vpc-logs/',
},
{
dataSource: 'aws-rds',
@@ -3837,7 +3837,7 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
link: '/docs/openai-monitoring/',
link: '/docs/llm/opentelemetry-openai-monitoring/',
},
{
dataSource: 'codex-monitoring',
@@ -4779,7 +4779,7 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-flutter/',
},
{
dataSource: 'ios-swift',
dataSource: 'swift',
label: 'iOS Swift',
imgUrl: swiftMonitoringUrl,
tags: ['mobile app monitoring'],
@@ -4808,7 +4808,6 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
id: 'ios-swift',
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-swiftui/',
},
{
@@ -5474,7 +5473,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
id: 'hasura',
link: '/docs/integrations/outposts/hasura/',
link: '/docs/integrations/opentelemetry-hasura/',
},
{
dataSource: 'opencode-observability',
@@ -5936,7 +5935,7 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
],
link: '/docs/integrations/outposts/flyio/',
link: '/docs/integrations/flyio/',
},
{
dataSource: 'envoy',
@@ -6514,7 +6513,7 @@ const onboardingConfigWithLinks = [
'tracing',
],
id: 'openclaw-observability',
link: '/docs/openclaw-observability/',
link: '/docs/openclaw-monitoring/',
},
{
dataSource: 'claude-agent-monitoring',
@@ -6556,7 +6555,7 @@ const onboardingConfigWithLinks = [
'render monitoring',
],
id: 'render-metrics',
link: '/docs/integrations/outposts/render/',
link: '/docs/metrics-management/render-metrics/',
},
{
dataSource: 'cert-manager',
@@ -6580,7 +6579,7 @@ const onboardingConfigWithLinks = [
'tls',
],
id: 'cert-manager',
link: '/docs/infrastructure-monitoring/user-guides/cert-manager/',
link: '/docs/infrastructure-monitoring/cert-manager/',
},
{
dataSource: 'pgbouncer',

View File

@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -339,7 +339,6 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'metrics',
'quick-filter',
'role',
'saved-view',
'serviceaccount',
'subscription',
'traces',
@@ -423,7 +422,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -433,7 +432,6 @@ describe('createEmptyRolePermissions', () => {
'metrics',
'quick-filter',
'role',
'saved-view',
'serviceaccount',
'subscription',
'traces',

View File

@@ -10,7 +10,6 @@ import {
Logs,
Receipt,
Shield,
TowerControl,
} from '@signozhq/icons';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
@@ -96,14 +95,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
'saved-view': {
label: 'Saved Views',
description: 'Saved views in the logs, traces, metrics and meter explorers.',
icon: TowerControl,
selectorPlaceholder:
'Type saved view ID, separate multiple with comma or space',
docsAnchor: 'saved-view',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -1,50 +1,41 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import {
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
viewId,
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<AuthZDropdownItemType[]>(
const items = useMemo<DropdownItemType[]>(
() => [
{
type: 'item',
value: 'save-as-new',
label: 'Save as new view',
checks: [SavedViewCreatePermission],
onClick: onSaveAsNew,
},
{
type: 'item',
value: 'update',
label: 'Update selected view',
checks: [buildSavedViewUpdatePermission(viewId)],
onClick: onUpdate,
},
],
[viewId, onSaveAsNew, onUpdate],
[onSaveAsNew, onUpdate],
);
return (
<AuthZDropdown
<Dropdown
items={items}
nativeButton
align="end"
side="bottom"
disabled={disabled}
disabledTooltip="Saving the view"
testId="saved-views-save-changes"
>
<SavedViewsIconButton
title="Save changes"
@@ -52,7 +43,7 @@ function SaveChangesMenu({
color="warning"
testId="saved-views-save-changes"
/>
</AuthZDropdown>
</Dropdown>
);
}

View File

@@ -1,8 +1,7 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SaveViewModal from './SaveViewModal';
import { SavedViewsEmptyStateProps } from './types';
@@ -32,8 +31,7 @@ function SavedViewsEmptyState({
<Typography.Text className={styles.stateText}>
Save the current view to open it later.
</Typography.Text>
<AuthZButton
checks={[SavedViewCreatePermission]}
<Button
variant="outlined"
color="secondary"
size="sm"
@@ -42,7 +40,7 @@ function SavedViewsEmptyState({
testId="saved-views-list-create"
>
Create view
</AuthZButton>
</Button>
{isModalOpen && (
<SaveViewModal
mode="create"

View File

@@ -5,17 +5,9 @@ import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { Tooltip } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildSavedViewReadPermission,
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
MY_VIEW_NAME,
SAVED_VIEW_FORBIDDEN_NAME,
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
@@ -39,16 +31,8 @@ function SavedViewsHeader({
onOpenViews?: () => void;
onCollapse?: () => void;
}): JSX.Element {
const {
view,
isLoading,
isError,
isReadDenied,
errorMessage,
hasUnsavedChanges,
} = useActiveSavedView(source);
const { viewKey } = useGetSavedViewParams();
const readPermission = buildSavedViewReadPermission(viewKey);
const { view, isLoading, isError, hasUnsavedChanges } =
useActiveSavedView(source);
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
@@ -65,27 +49,11 @@ function SavedViewsHeader({
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
let name = view?.spec.displayName ?? MY_VIEW_NAME;
if (isReadDenied) {
name = SAVED_VIEW_FORBIDDEN_NAME;
} else if (isError) {
name = SAVED_VIEW_LOAD_FAILED_NAME;
}
const name = isError
? SAVED_VIEW_LOAD_FAILED_NAME
: (view?.spec.displayName ?? MY_VIEW_NAME);
const isDirty = !isError && !!view && hasUnsavedChanges;
const nameChip = (
<span
className={cx(styles.name, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
);
return (
<div
className={cx(styles.header, {
@@ -100,7 +68,6 @@ function SavedViewsHeader({
<SavedViewsIconButton
title="All views"
icon={<List size={14} />}
checks={[SavedViewListPermission]}
onClick={onOpenViews}
testId="saved-views-open"
/>
@@ -112,10 +79,19 @@ function SavedViewsHeader({
className={styles.skeleton}
testId="saved-views-loading"
/>
) : isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
<Tooltip title={name}>
<span
className={cx(styles.name, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</Tooltip>
)}
</div>
@@ -126,7 +102,6 @@ function SavedViewsHeader({
icon={<Plus size={14} />}
disabled={isLoading}
disabledTooltip="Loading views"
checks={[SavedViewCreatePermission]}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
@@ -142,7 +117,6 @@ function SavedViewsHeader({
{view && isDirty && (
<>
<SaveChangesMenu
viewId={view.id}
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {

View File

@@ -1,7 +1,6 @@
import { forwardRef } from 'react';
import { Button, type VariantColorType } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SavedViewsIconButtonProps } from './types';
@@ -26,33 +25,11 @@ const SavedViewsIconButton = forwardRef<
color = 'secondary',
disabled,
disabledTooltip,
checks,
onClick,
...triggerProps
},
ref,
): JSX.Element {
if (checks?.length) {
return (
<Tooltip title={title}>
<AuthZButton
checks={checks}
{...VARIANT_COLORS[color]}
size="sm"
icon
aria-label={title}
disabled={disabled}
// AuthZButton skips its checks while a block reason is set.
disabledTooltip={disabled ? disabledTooltip : undefined}
onClick={onClick}
testId={testId}
>
{icon}
</AuthZButton>
</Tooltip>
);
}
return (
<Tooltip title={title}>
<Button

View File

@@ -41,7 +41,7 @@
.row {
display: flex;
align-items: center;
gap: var(--spacing-4);
gap: var(--spacing-2);
height: var(--spacing-16);
padding-right: var(--spacing-4);
border-radius: var(--radius-2);
@@ -71,7 +71,7 @@
.row:hover .rowMenu,
.row:focus-within .rowMenu,
.rowMenu:has([data-popup-open]) {
.rowMenu:has([data-state='open']) {
width: auto;
opacity: 1;
}

View File

@@ -40,14 +40,8 @@ function SavedViewsList({
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
useSavedViewsList(source, search);
const {
copyViewLink,
copyViewId,
renameView,
deleteView,
isRenaming,
isDeleting,
} = useSavedViewRowActions(source);
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
useSavedViewRowActions(source);
const [pendingAction, setPendingAction] =
useState<PendingSavedViewAction | null>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -85,14 +79,10 @@ function SavedViewsList({
copyViewLink(view);
return;
}
if (key === 'copyId') {
copyViewId(view);
return;
}
closeHoverCard();
setPendingAction({ key, view });
},
[copyViewLink, copyViewId, closeHoverCard],
[copyViewLink, closeHoverCard],
);
const closePendingAction = useCallback((): void => setPendingAction(null), []);

View File

@@ -1,8 +1,6 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { Button } from '@signozhq/ui/button';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowMenu from './SavedViewsRowMenu';
@@ -19,26 +17,17 @@ function SavedViewsListRow({
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
const readPermission = buildSavedViewReadPermission(view.id);
// Same cached check as the button; gates the hover card.
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
const canRead = !isLoading && deniedPermissions.length === 0;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void => {
if (canRead) {
hover.onRowEnter(view, event.currentTarget);
}
}}
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
onMouseLeave={hover.onRowLeave}
>
<div className={styles.rowName}>
<AuthZButton
checks={[readPermission]}
<Button
size="md"
variant="ghost"
color="secondary"
@@ -52,7 +41,7 @@ function SavedViewsListRow({
testId="saved-views-row-name"
>
{name}
</AuthZButton>
</Button>
</div>
{isActive && (
<SavedViewsRowButton
@@ -65,7 +54,6 @@ function SavedViewsListRow({
)}
<span className={styles.rowMenu}>
<SavedViewsRowMenu
viewId={view.id}
onAction={(key): void => onAction(view, key)}
onOpen={hover.onMenuOpen}
/>

View File

@@ -4,9 +4,6 @@ import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
@@ -42,20 +39,16 @@ function SavedViewsPanel({
/>
</div>
)}
<AuthZGuardContent checks={[SavedViewListPermission]}>
<>
<div className={styles.search}>
<Input
value={search}
onChange={(event): void => setSearch(event.target.value)}
placeholder="Search..."
aria-label="Search views"
testId="saved-views-search"
/>
</div>
<SavedViewsList source={source} search={search} />
</>
</AuthZGuardContent>
<div className={styles.search}>
<Input
value={search}
onChange={(event): void => setSearch(event.target.value)}
placeholder="Search..."
aria-label="Search views"
testId="saved-views-search"
/>
</div>
<SavedViewsList source={source} search={search} />
</div>
);
}

View File

@@ -1,18 +1,16 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
import SavedViewsRowButton from './SavedViewsRowButton';
import { SavedViewsRowMenuProps } from './types';
function SavedViewsRowMenu({
viewId,
onAction,
onOpen,
}: SavedViewsRowMenuProps): JSX.Element {
const items = useMemo<AuthZDropdownItemType[]>(
const items = useMemo<DropdownItemType[]>(
() =>
SAVED_VIEW_ROW_ACTIONS.flatMap(
({
@@ -21,8 +19,7 @@ function SavedViewsRowMenu({
icon: Icon,
danger,
hasDividerBefore,
buildPermission,
}): AuthZDropdownItemType[] => [
}): DropdownItemType[] => [
...(hasDividerBefore
? [{ type: 'separator' as const, value: `separator-${key}` }]
: []),
@@ -32,16 +29,15 @@ function SavedViewsRowMenu({
label,
prefix: <Icon size={14} />,
danger,
checks: buildPermission ? [buildPermission(viewId)] : undefined,
onClick: (): void => onAction(key),
},
],
),
[viewId, onAction],
[onAction],
);
return (
<AuthZDropdown
<Dropdown
items={items}
nativeButton
side="right"
@@ -51,7 +47,6 @@ function SavedViewsRowMenu({
onOpen();
}
}}
testId="saved-views-row-menu"
>
<SavedViewsRowButton
icon={<Ellipsis size={14} />}
@@ -59,7 +54,7 @@ function SavedViewsRowMenu({
tooltip="More options"
testId="saved-views-row-menu"
/>
</AuthZDropdown>
</Dropdown>
);
}

View File

@@ -1,10 +1,4 @@
import {
act,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import { act, fireEvent, screen, within } from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -18,7 +12,6 @@ import {
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
queryWith,
@@ -117,11 +110,6 @@ async function renderPanel(
dataSource,
);
await screen.findByTestId('saved-views-list');
await waitFor(() =>
screen
.getAllByTestId('saved-views-row-name')
.forEach((name) => expectUsable(name)),
);
jest.useFakeTimers();
}
@@ -152,10 +140,6 @@ describe('SavedViewHoverCard', () => {
});
afterEach(() => {
// useAuthZ batches checks on a timer; left pending, it blocks later checks.
act(() => {
jest.runOnlyPendingTimers();
});
jest.useRealTimers();
});

View File

@@ -1,265 +0,0 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
buildSavedViewDeletePermission,
buildSavedViewReadPermission,
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
AUTHZ_CHECK_URL,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderPanel(url = explorerUrl(PATH, {})): void {
renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
url,
);
}
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const rowNamed = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((row) => row.textContent?.includes(name)) as HTMLElement;
async function openRowMenu(name: string): Promise<void> {
await screen.findAllByTestId('saved-views-row');
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
}
// Resolves once the row's check is back, so its state is final.
async function settledRow(testId: string): Promise<HTMLElement> {
await waitFor(
() => expect(screen.getByTestId(testId)).not.toHaveAttribute('data-loading'),
MENU_CHECK_TIMEOUT,
);
return screen.getByTestId(testId);
}
describe('SavedViews - AuthZ', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
server.resetHandlers();
});
it('shows the denied callout instead of the list without list', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(SavedViewListPermission));
renderPanel();
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-list')).toBeNull();
expect(screen.queryByTestId('saved-views-search')).toBeNull();
});
it('disables only the views it cannot read', async () => {
mockSavedViewsApi([errors, slow]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderPanel();
await screen.findAllByTestId('saved-views-row');
const nameOf = (view: string): HTMLElement =>
within(rowNamed(view)).getByTestId('saved-views-row-name');
await waitFor(() =>
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
);
await waitFor(() =>
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
);
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
});
it('keeps the row menu on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderPanel();
await openRowMenu('Errors');
await expect(
settledRow('saved-views-row-menu-item-delete'),
).resolves.not.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-editDetails'),
).resolves.not.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-copyLink'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables creating without create', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewCreatePermission));
renderPanel();
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(screen.getByTestId('saved-views-list-create')).toHaveAttribute(
'aria-disabled',
'true',
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-create')).toHaveAttribute(
'aria-disabled',
'true',
),
);
});
it('keeps create disabled while the check is pending', async () => {
mockSavedViewsApi([]);
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderHeader(explorerUrl(PATH, {}));
const create = screen.getByTestId('saved-views-create');
expect(create).toHaveAttribute('aria-busy', 'true');
await userEvent.click(create);
expect(screen.queryByTestId('save-view-name')).toBeNull();
});
it('disables edit details without update and delete without delete', async () => {
mockSavedViewsApi([errors]);
server.use(
setupAuthzDeny(
buildSavedViewUpdatePermission(errors.id),
buildSavedViewDeletePermission(errors.id),
),
);
renderPanel();
await openRowMenu('Errors');
await expect(
settledRow('saved-views-row-menu-item-editDetails'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-delete'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-copyLink'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables updating the open view without update, keeping save as new', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewUpdatePermission(errors.id)));
const { history } = renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
);
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
await expect(
settledRow('saved-views-save-changes-item-update'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-save-changes-item-save-as-new'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('says no access when the check denies read, even if the server returns the view', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveAttribute(
'data-denied-permissions',
),
);
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
await waitFor(() =>
expect(
screen.queryAllByText(/is not authorized to perform read:saved-view:view-1/)
.length,
).toBeGreaterThan(0),
);
});
it('treats a 403 the check allowed as a failed load, showing the server message', async () => {
const message = 'saved-view:read is missing, ask an admin for it';
mockSavedViewsApi([errors]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(
ctx.status(403),
ctx.json({ status: 'error', error: { code: 'forbidden', message } }),
),
),
);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'View failed to load',
),
);
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
await waitFor(() =>
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
);
});
});

View File

@@ -9,9 +9,7 @@ import { usePreferenceContext } from 'providers/preferences/context/PreferenceCo
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
@@ -117,11 +115,9 @@ describe('SavedViewsHeader columns', () => {
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
const update = await screen.findByTestId(
'saved-views-save-changes-item-update',
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Update selected view' }),
);
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
await userEvent.click(update);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));

View File

@@ -9,11 +9,8 @@ import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
urlParam,
@@ -70,21 +67,9 @@ async function openDirtyView(
return rendered;
}
const SAVE_MENU_ITEM_TEST_IDS: Record<string, string> = {
'Save as new view': 'saved-views-save-changes-item-save-as-new',
'Update selected view': 'saved-views-save-changes-item-update',
};
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
const menuItem = await screen.findByTestId(SAVE_MENU_ITEM_TEST_IDS[item]);
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
await userEvent.click(menuItem);
}
async function clickCreate(): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId('saved-views-create')));
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
@@ -102,7 +87,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await userEvent.click(screen.getByTestId('saved-views-create'));
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toHaveAttribute(
@@ -145,7 +130,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
@@ -160,7 +145,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));

View File

@@ -8,7 +8,6 @@ import ROUTES from 'constants/routes';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
renderWithExplorerProviders,
@@ -85,9 +84,9 @@ describe('SavedViewsPanel list', () => {
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
await waitFor(() => expectUsable(name));
await userEvent.click(name);
await userEvent.click(
within(row('Slow spans')).getByTestId('saved-views-row-name'),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
@@ -162,9 +161,7 @@ describe('SavedViewsPanel list', () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderPanel();
const create = await screen.findByTestId('saved-views-list-create');
await waitFor(() => expectUsable(create));
await userEvent.click(create);
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
await userEvent.click(screen.getByTestId('save-view-submit'));

View File

@@ -9,10 +9,8 @@ import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
urlParam,
viewUrl,
@@ -60,22 +58,13 @@ const row = (name: string): HTMLElement =>
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const ROW_MENU_ITEM_TEST_IDS: Record<string, string> = {
'Copy link': 'saved-views-row-menu-item-copyLink',
'Copy ID': 'saved-views-row-menu-item-copyId',
'Edit details': 'saved-views-row-menu-item-editDetails',
Delete: 'saved-views-row-menu-item-delete',
};
async function chooseFromRowMenu(
viewName: string,
item: string,
): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
const menuItem = await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS[item]);
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
fireEvent.click(menuItem);
fireEvent.click(await screen.findByRole('menuitem', { name: item }));
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
@@ -87,38 +76,21 @@ describe('SavedViewsPanel row menu', () => {
jest.mocked(toast.success).mockClear();
});
it('offers copy link, copy id, edit details and delete', async () => {
it('offers copy link, edit details and delete', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader();
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS['Copy link']);
const items = Object.values(ROW_MENU_ITEM_TEST_IDS).map(
(testId) => screen.getByTestId(testId).textContent,
);
expect(items).toStrictEqual([
const items = await screen.findAllByRole('menuitem');
expect(items.map((item) => item.textContent)).toStrictEqual([
'Copy link',
'Copy ID',
'Edit details',
'Delete',
]);
});
it('copies the view id', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Copy ID');
expect(mockCopy).toHaveBeenCalledWith(slow.id);
expect(toast.success).toHaveBeenCalledWith(
'View ID copied',
expect.anything(),
);
});
it('copies a link that opens the view with its query and tab', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();

View File

@@ -1,5 +1,4 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
@@ -14,7 +13,6 @@ export interface Requests {
}
// Mutates `views` on create, update and delete, so a refetch sees the change.
// Grants every permission; a later server.use overrides it.
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
@@ -32,7 +30,6 @@ export function mockSavedViewsApi(
const requests: Requests = { created: [], updated: [], deleted: [] };
let failedLists = 0;
server.use(
setupAuthzAdmin(),
rest.get(API, (req, res, ctx) => {
if (failedLists < listFailures) {
failedLists += 1;

View File

@@ -251,20 +251,3 @@ export function renderWithExplorerProviders(
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}
// The ui Button marks a pending check with aria-busy and a block with
// aria-disabled, never the disabled attribute.
export function expectUsable(element: HTMLElement): void {
expect(element).not.toHaveAttribute('aria-busy');
expect(element).not.toHaveAttribute('aria-disabled', 'true');
}
// A menu checks its rows on first open; on a loaded CI runner that outlasts
// waitFor's 1s default.
export const MENU_CHECK_TIMEOUT = { timeout: 5000 };
// A menu row waiting on its check is data-loading, not yet data-disabled.
export function expectRowUsable(row: HTMLElement): void {
expect(row).not.toHaveAttribute('data-loading');
expect(row).not.toHaveAttribute('data-disabled');
}

View File

@@ -1,13 +1,9 @@
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import { Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
@@ -34,8 +30,6 @@ export const MY_VIEW_NAME = 'My view';
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
export const SAVED_VIEW_FORBIDDEN_NAME = 'No view access';
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
@@ -53,20 +47,13 @@ export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
{ key: 'copyLink', label: 'Copy link', icon: Link },
{ key: 'copyId', label: 'Copy ID', icon: Copy },
{
key: 'editDetails',
label: 'Edit details',
icon: PenLine,
buildPermission: buildSavedViewUpdatePermission,
},
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
{
key: 'delete',
label: 'Delete',
icon: Trash2,
danger: true,
hasDividerBefore: true,
buildPermission: buildSavedViewDeletePermission,
},
];

View File

@@ -6,10 +6,7 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
@@ -26,19 +23,11 @@ export function useActiveSavedView(
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
[buildSavedViewReadPermission(viewKey)],
{ enabled: !!viewKey },
);
const isReadDenied = !!viewKey && deniedPermissions.length > 0;
const canFetch = !!viewKey && !isCheckingRead && !isReadDenied;
const { data, isLoading, isError, error } = useGetSavedView(
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: canFetch } },
{ query: { enabled: !!viewKey } },
);
const view = canFetch ? data?.data : undefined;
const view = viewKey ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
@@ -79,17 +68,10 @@ export function useActiveSavedView(
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
const errorMessage =
viewKey && isError && error
? toAPIError(error, '').getErrorMessage() || undefined
: undefined;
return {
view,
isLoading: !!viewKey && (isCheckingRead || isLoading),
isError: !!viewKey && (isError || isReadDenied),
isReadDenied,
errorMessage,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
hasUnsavedChanges,
};
}

View File

@@ -59,14 +59,6 @@ export function useSavedViewRowActions(
[pathname, copyToClipboard],
);
const copyViewId = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
copyToClipboard(view.id);
toast.success('View ID copied', { position: SAVED_VIEW_TOAST_POSITION });
},
[copyToClipboard],
);
const renameView = useCallback(
async (
view: SavedviewtypesSavedViewDTO,
@@ -109,12 +101,5 @@ export function useSavedViewRowActions(
[deleteSavedView, source, queryClient],
);
return {
copyViewLink,
copyViewId,
renameView,
deleteView,
isRenaming,
isDeleting,
};
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
}

View File

@@ -1,218 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest, type RequestHandler } from 'msw';
import remove from 'api/browser/localstorage/remove';
import {
type AuthtypesTransactionDTO,
type SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
type SavedviewtypesUpdatableSavedViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { accessFor } from '@/storybook/access/access';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
import { respondWith } from '@/storybook/runtime/responseState';
import {
createdSavedView,
OPEN_VIEW_ERRORS,
OPEN_VIEWS,
type OpenView,
SAVED_VIEW_SOURCES,
SAVED_VIEWS_MAX,
savedViewById,
savedViewResponse,
savedViewRoute,
savedViewsListResponse,
type SavedViewSource,
unreadableViewId,
} from './__story_mockdata__/savedViews';
const API = 'http://localhost/api/v2/saved_views';
const LIST = 'Saved Views · list';
const VIEW = 'Saved Views · open view';
const controls = {
source: choiceControl<SavedViewSource>('Source', {
group: VIEW,
description: 'Which explorer the views belong to.',
options: SAVED_VIEW_SOURCES,
value: SavedviewtypesSourceDTO.traces,
}),
openView: choiceControl<OpenView>('Open view', {
group: VIEW,
description:
'The `viewKey` the explorer url carries. `unsaved-changes` stages a query that differs from the view, which swaps in the ✓ save menu; `forbidden`, `failed` and `not-found` are the 403, 500 and 404 the view fetch answers with; each shows View failed to load.',
options: OPEN_VIEWS,
value: 'none',
}),
unreadableView: toggleControl('Unreadable view', {
group: VIEW,
description:
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
value: false,
}),
createdByMe: countControl('Created by me', {
group: LIST,
value: 3,
max: SAVED_VIEWS_MAX,
}),
createdByOthers: countControl('Created by others', {
group: LIST,
description: 'Both at 0 is the empty state, with its Create view button.',
value: 4,
max: SAVED_VIEWS_MAX,
}),
};
type SavedViewsValues = MockControlValues<typeof controls>;
type WrittenView = Pick<SavedviewtypesSavedViewDTO, 'spec' | 'source'>;
const viewDenied = (
transaction: AuthtypesTransactionDTO,
deniedId: string,
): boolean =>
transaction.relation === 'read' &&
transaction.object.resource.kind === 'saved-view' &&
transaction.object.selector === deniedId;
const definition = defineStoryMocks({
controls,
handlers: (
values: SavedViewsValues & AuthzArgs,
response,
): RequestHandler[] => {
const { source, openView, createdByMe, createdByOthers } = values;
// What the panel writes stays in this handler set until a control changes.
const created: SavedviewtypesSavedViewDTO[] = [];
const updated = new Map<string, WrittenView>();
const deleted = new Set<string>();
const current = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
const view = created.find((item) => item.id === id) ?? savedViewById(id);
return view && !deleted.has(id) ? current(view) : undefined;
};
const openError = OPEN_VIEW_ERRORS[openView];
return [
rest.post(API, async (req, res, ctx) => {
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
const id = `storybook-${source}-created-${created.length + 1}`;
created.push(createdSavedView(source, id, body.spec));
return res(ctx.status(201), ctx.json({ status: 'success', data: { id } }));
}),
rest.get(
API,
response.json(() => {
const list = savedViewsListResponse(source, createdByMe, createdByOthers);
return {
...list,
data: [...(list.data ?? []), ...created]
.filter((view) => !deleted.has(view.id))
.map(current),
};
}),
),
rest.get(`${API}/:id`, (req, res, ctx) => {
if (openError) {
return res(ctx.status(openError.status), ctx.json(openError.body));
}
const view = findView(String(req.params.id));
if (!view) {
return res(ctx.status(404), ctx.json(OPEN_VIEW_ERRORS['not-found']?.body));
}
return response.json(() => savedViewResponse(view))(req, res, ctx);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
updated.set(String(req.params.id), {
spec: body.spec,
source: body.source,
});
return res(ctx.status(204));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
deleted.add(String(req.params.id));
return res(ctx.status(204));
}),
...(values.unreadableView
? [
rest.post(
AUTHZ_CHECK_URL,
respondWith(values.authzState, async (req) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const granted = accessFor(
values.access,
values.permissions,
values.revoked,
);
const deniedId = unreadableViewId(source);
return authzMockResponse(
payload,
payload.map(
(transaction) =>
!viewDenied(transaction, deniedId) && granted.allows(transaction),
),
);
}),
),
]
: []),
];
},
config: ({ source, openView }) => ({
route: savedViewRoute(source, openView),
}),
effect: () => {
remove(LOCALSTORAGE.LAST_USED_SAVED_VIEWS);
},
});
/**
* The per-view read denial answers `authz/check` in place of the global access
* mocks, so it reads their controls as well as its own to answer every other
* check the way they would.
*/
export const savedViewsMocks: StoryMocks<typeof controls> = {
...definition,
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
};

View File

@@ -1,114 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { savedViewsMocks } from './SavedViews.stories.mocks';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const componentStory = storyMocks(savedViewsMocks);
/** The panel fetches the list and checks every row's permissions before it settles. */
const untilLoaded = { timeout: 15_000 };
/**
* The saved views sidebar of an explorer without quick filters: the header with
* the open view and its actions, above the list of views created by the user
* and by others. Every action is gated on the `saved-view` authz permissions
* (list, create, and read, update and delete per view).
*
* Route: the explorer of the Source control, with the Open view control's
* `viewKey`.
*/
const meta = {
title: 'Components/Saved Views',
tags: ['authz', 'play'],
component: SavedViewsPanel,
decorators: [
withCanvas({
width: 280,
height: 640,
padding: 0,
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid var(--l1-border)',
}),
],
render: ({ source }): JSX.Element => (
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
<SavedViewsHeader source={source} />
<SavedViewsPanel source={source} />
</TooltipProvider>
),
...componentStory,
parameters: { ...componentStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
/** Traces views created by the user and by others, with no view open. */
export const Default: Story = {};
/** A workspace with no views yet: the empty state offers Create view. */
export const Empty: Story = {
args: { createdByMe: 0, createdByOthers: 0 },
};
/**
* A view open with a query that differs from what it saved: the name carries
* the unsaved dot, and ✓ and discard replace Clear view.
*/
export const UnsavedChanges: Story = {
args: { openView: 'unsaved-changes' },
};
/** Without `list` on saved views, the panel shows the permission callout. */
export const NoAccess: Story = {
args: { revoked: ['list:saved-view'] },
};
/**
* The first row's menu. Edit details and Delete follow `update` and `delete` on
* that view, so revoking either disables its item.
*/
export const RowMenu: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
'saved-views-row',
undefined,
untilLoaded,
);
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, untilLoaded);
},
};
/**
* The ✓ menu on a view with unsaved changes. Save as new view follows `create`
* and Update selected view follows `update` on the open view.
*/
export const SaveChangesMenu: Story = {
args: { openView: 'unsaved-changes' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(async () => {
const button = within(canvasElement).getByTestId('saved-views-save-changes');
await expect(button).not.toHaveAttribute('aria-busy');
return button;
}, untilLoaded);
await userEvent.click(trigger);
await screen.findByRole('menu', undefined, untilLoaded);
},
};

View File

@@ -1,309 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type {
GetSavedView200,
ListSavedViews200,
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSpec } from 'container/SavedViews/utils/toSavedViewSpec';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
export const SAVED_VIEW_SOURCES = [
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.metrics,
SavedviewtypesSourceDTO.meter,
] as const;
export type SavedViewSource = (typeof SAVED_VIEW_SOURCES)[number];
export const OPEN_VIEWS = [
'none',
'created-by-me',
'created-by-others',
'unsaved-changes',
'forbidden',
'failed',
'not-found',
] as const;
export type OpenView = (typeof OPEN_VIEWS)[number];
export const SAVED_VIEWS_MAX = 8;
/** The user the story app context signs in as, so "Created by me" follows it. */
const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user.email;
const OTHER_AUTHORS = ['priya@signoz.io', 'marco@signoz.io', 'lena@signoz.io'];
const MY_VIEW_NAMES = [
'Checkout errors',
'Slow payment calls',
'Cart service, production',
'Auth retries',
'Inventory sync',
'Search p99 regressions',
'Webhook failures',
'Cold starts after deploy',
];
const OTHER_VIEW_NAMES = [
'On-call triage',
'Frontend 5xx',
'Shipping latency by region',
'Kafka consumer lag',
'Staging smoke tests',
'Recommendation timeouts',
'Ad service sampling',
'Email queue backlog',
];
const EXPRESSIONS = [
"service.name = 'checkout' AND has_error = true",
"service.name = 'payments'",
"service.name = 'cart' AND deployment.environment = 'production'",
"service.name = 'auth'",
"service.name IN ('inventory', 'catalog')",
"service.name = 'search'",
"service.name = 'webhooks' AND has_error = true",
"deployment.environment = 'production'",
];
const BASE_QUERY: Record<SavedViewSource, Query> = {
traces: initialQueriesMap.traces,
logs: initialQueriesMap.logs,
metrics: initialQueriesMap.metrics,
meter: initialQueryMeterWithType,
};
const EXPLORER_ROUTE: Record<SavedViewSource, string> = {
traces: ROUTES.TRACES_EXPLORER,
logs: ROUTES.LOGS_EXPLORER,
metrics: ROUTES.METRICS_EXPLORER_EXPLORER,
meter: ROUTES.METER_EXPLORER,
};
const queryWith = (source: SavedViewSource, expression: string): Query => {
const base = BASE_QUERY[source];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
};
type Author = 'mine' | 'others';
const viewId = (
source: SavedViewSource,
author: Author,
index: number,
): string => `storybook-${source}-${author}-${index + 1}`;
/** The view other people made that the per-view read denial applies to. */
export const unreadableViewId = (source: SavedViewSource): string =>
viewId(source, 'others', 0);
const savedView = (
source: SavedViewSource,
author: Author,
index: number,
): SavedviewtypesSavedViewDTO => {
const displayName = (author === 'mine' ? MY_VIEW_NAMES : OTHER_VIEW_NAMES)[
index
];
const createdBy =
author === 'mine'
? STORY_USER_EMAIL
: OTHER_AUTHORS[index % OTHER_AUTHORS.length];
const id = viewId(source, author, index);
return {
id,
name: id,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdAt: '2026-09-02T08:30:00Z',
createdBy,
updatedAt: `2026-10-${String(14 - (index % 10)).padStart(2, '0')}T11:42:00Z`,
updatedBy: createdBy,
spec: toSavedViewSpec({
query: queryWith(
source,
EXPRESSIONS[(index + (author === 'mine' ? 0 : 3)) % EXPRESSIONS.length],
),
panelType: PANEL_TYPES.LIST,
displayName,
}),
};
};
const savedViews = (
source: SavedViewSource,
author: Author,
count: number,
): SavedviewtypesSavedViewDTO[] =>
Array.from({ length: Math.min(count, SAVED_VIEWS_MAX) }, (_, index) =>
savedView(source, author, index),
);
export const savedViewsListResponse = (
source: SavedViewSource,
createdByMe: number,
createdByOthers: number,
): ListSavedViews200 => ({
status: 'success',
data: [
...savedViews(source, 'mine', createdByMe),
...savedViews(source, 'others', createdByOthers),
],
});
/** Every view the workspace has, whatever the list control trims it to. */
export const savedViewById = (
id: string,
): SavedviewtypesSavedViewDTO | undefined =>
SAVED_VIEW_SOURCES.flatMap((source) => [
...savedViews(source, 'mine', SAVED_VIEWS_MAX),
...savedViews(source, 'others', SAVED_VIEWS_MAX),
]).find((view) => view.id === id);
/** A view the panel itself created, owned by the story user. */
export const createdSavedView = (
source: SavedViewSource,
id: string,
spec: SavedviewtypesSavedViewDTO['spec'],
): SavedviewtypesSavedViewDTO => ({
...savedView(source, 'mine', 0),
id,
name: id,
createdAt: '2026-10-16T09:00:00Z',
updatedAt: '2026-10-16T09:00:00Z',
spec,
});
export const savedViewResponse = (
view: SavedviewtypesSavedViewDTO,
): GetSavedView200 => ({ status: 'success', data: view });
export const OPEN_VIEW_ERRORS: Partial<
Record<OpenView, { status: number; body: RenderErrorResponseDTO }>
> = {
forbidden: {
status: 403,
body: {
status: 'error',
error: {
code: 'forbidden',
message: 'You do not have access to this saved view',
errors: [],
suggestions: [],
type: 'forbidden',
},
},
},
failed: {
status: 500,
body: {
status: 'error',
error: {
code: 'internal',
message: 'Failed to load the saved view',
errors: [],
suggestions: [],
type: 'internal',
},
},
},
'not-found': {
status: 404,
body: {
status: 'error',
error: {
code: 'not_found',
message: 'Saved view not found',
errors: [],
suggestions: [],
type: 'not_found',
},
},
},
};
const openViewId = (source: SavedViewSource, open: OpenView): string | null => {
if (open === 'none') {
return null;
}
if (open === 'created-by-others' || open === 'forbidden') {
return viewId(source, 'others', 0);
}
if (open === 'not-found') {
return `storybook-${source}-deleted`;
}
return viewId(source, 'mine', 0);
};
/**
* The explorer url with a view open, its params written the way
* `redirectWithQueryBuilderData` writes them. `panelTypes` is left out: the
* query builder seeds its panel type from the raw, JSON-quoted param, which no
* saved panel type equals, and without it the header compares against `list`.
*/
export const savedViewRoute = (
source: SavedViewSource,
open: OpenView,
): string => {
const params = new URLSearchParams({ relativeTime: '15m' });
const id = openViewId(source, open);
if (id) {
const view = savedViewById(id);
const query = view
? getSavedViewQuery(view)
: queryWith(source, EXPRESSIONS[0]);
const staged =
open === 'unsaved-changes'
? queryWith(source, `${EXPRESSIONS[0]} AND duration_nano > 500000000`)
: query;
params.set(
QueryParams.compositeQuery,
encodeURIComponent(
JSON.stringify({ ...staged, id: `storybook-staged-${open}` }),
),
);
params.set(QueryParams.viewKey, JSON.stringify(id));
}
return `${EXPLORER_ROUTE[source]}?${params.toString()}`;
};

View File

@@ -1,6 +1,5 @@
import { ReactElement, ReactNode } from 'react';
import type { Link } from '@signozhq/icons';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
@@ -42,8 +41,6 @@ export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
isReadDenied: boolean;
errorMessage: string | undefined;
hasUnsavedChanges: boolean;
}
@@ -73,7 +70,6 @@ export interface SaveViewModalProps {
}
export interface SaveChangesMenuProps {
viewId: string;
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
@@ -86,7 +82,6 @@ export interface SavedViewsIconButtonProps {
color?: 'secondary' | 'warning';
disabled?: boolean;
disabledTooltip?: ReactNode;
checks?: BrandedPermission[];
onClick?: () => void;
}
@@ -121,11 +116,7 @@ export interface SavedViewsListRowProps {
hover: SavedViewsListRowHoverProps;
}
export type SavedViewRowActionKey =
| 'copyLink'
| 'copyId'
| 'editDetails'
| 'delete';
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
export interface SavedViewRowActionConfig {
key: SavedViewRowActionKey;
@@ -133,11 +124,10 @@ export interface SavedViewRowActionConfig {
icon: typeof Link;
danger?: boolean;
hasDividerBefore?: boolean;
buildPermission?: (id: string) => BrandedPermission;
}
export interface PendingSavedViewAction {
key: Exclude<SavedViewRowActionKey, 'copyLink' | 'copyId'>;
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
view: SavedviewtypesSavedViewDTO;
}
@@ -150,7 +140,6 @@ export interface SavedViewsRowButtonProps {
}
export interface SavedViewsRowMenuProps {
viewId: string;
onAction: (key: SavedViewRowActionKey) => void;
onOpen: () => void;
}
@@ -163,7 +152,6 @@ export interface DeleteSavedViewDialogProps {
export interface UseSavedViewRowActionsResult {
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
copyViewId: (view: SavedviewtypesSavedViewDTO) => void;
renameView: (
view: SavedviewtypesSavedViewDTO,
displayName: string,

View File

@@ -28,9 +28,11 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
TOP_LIST: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -1,4 +1,3 @@
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { Widgets } from 'types/api/widgets/widget';
import {
MetricRangePayloadProps,
@@ -165,27 +164,6 @@ describe('BarPanel utils', () => {
expect(config.series).toHaveLength(3);
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -89,9 +89,6 @@ export function prepareBarPanelConfig({
isDarkMode,
stepInterval: currentStepInterval,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -5,7 +5,6 @@ import {
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -186,28 +185,6 @@ describe('TimeSeriesPanel utils', () => {
});
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -101,9 +101,6 @@ export const prepareUPlotConfig = ({
fillMode: widget.fillMode || FillMode.None,
isDarkMode,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
@@ -23,4 +24,5 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.TOP_LIST]: null,
};

View File

@@ -23,11 +23,6 @@ export default {
type: 'metaresource',
allowedVerbs: ['list', 'read', 'update'],
},
{
kind: 'saved-view',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',

View File

@@ -1,15 +0,0 @@
import type { BrandedPermission } from '../types';
import { buildPermission } from '../utils';
export const SavedViewListPermission = buildPermission('list', 'saved-view:*');
export const SavedViewCreatePermission = buildPermission(
'create',
'saved-view:*',
);
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `saved-view:${id}`);
export const buildSavedViewUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `saved-view:${id}`);
export const buildSavedViewDeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `saved-view:${id}`);

View File

@@ -1,42 +0,0 @@
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import { convertNewDataToOld } from '../convertNewDataToOld';
describe('convertNewDataToOld', () => {
it('carries partial values as epoch-second partialTimestamps', () => {
const payload = {
data: {
resultType: 'time_series',
result: [
{
queryName: 'A',
series: [
{
labels: {},
labelsArray: [],
values: [
{ timestamp: 1_000_000, value: '1' },
{ timestamp: 1_060_000, value: '0.2', partial: true },
],
},
{
labels: {},
labelsArray: [],
values: [{ timestamp: 1_000_000, value: '1' }],
},
],
},
],
},
} as unknown as MetricRangePayloadV3;
const [partial, complete] = convertNewDataToOld(payload).data.result;
expect(partial.values).toStrictEqual([
[1000, '1'],
[1060, '0.2'],
]);
expect(partial.partialTimestamps).toStrictEqual([1060]);
expect(complete).not.toHaveProperty('partialTimestamps');
});
});

View File

@@ -25,16 +25,11 @@ export const convertNewDataToOld = (
return [...acc, renderValues];
}, []);
const partialTimestamps = series.values
.filter((value) => value.partial)
.map((value) => value.timestamp / 1000);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
metaData: series.metaData,
...(partialTimestamps.length ? { partialTimestamps } : {}),
};
oldResult.push(result);

View File

@@ -14,8 +14,10 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
[PANEL_TYPES.TOP_LIST]: 'top_list';
};
export type BuilderField = keyof IBuilderQuery;
@@ -150,6 +152,8 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.TOP_LIST]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -72,3 +72,32 @@
var(--muted-foreground) 2px 3px
);
}
.vertical {
display: flex;
gap: 10px;
height: 100%;
min-height: 0;
.track {
flex: 0 0 8px;
width: 8px;
height: auto;
}
.marker {
top: auto;
right: -3px;
left: -3px;
width: auto;
height: 2px;
transform: translateY(1px);
}
}
.scale {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}

View File

@@ -14,6 +14,8 @@ export interface ColorBarProps {
label?: string;
/** Keys the one state a ramp cannot express: a hatched data gap. */
showNoDataKey?: boolean;
/** Vertical runs low to high from the bottom, with labels to the right. */
orientation?: 'horizontal' | 'vertical';
'data-testid'?: string;
}
@@ -25,8 +27,10 @@ export default function ColorBar({
markerPosition = null,
label,
showNoDataKey = false,
orientation = 'horizontal',
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const isVertical = orientation === 'vertical';
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
@@ -39,8 +43,8 @@ export default function ColorBar({
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
return `linear-gradient(to ${isVertical ? 'top' : 'right'}, ${stops.join(', ')})`;
}, [ramp, isVertical]);
if (gradient === undefined) {
return null;
@@ -49,6 +53,26 @@ export default function ColorBar({
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
if (isVertical) {
return (
<div className={Styles.vertical} data-testid={testId}>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ bottom: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<div className={Styles.scale}>
<span className={Styles.label}>{maxLabel}</span>
<span className={Styles.label}>{minLabel}</span>
</div>
</div>
);
}
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}

View File

@@ -82,4 +82,20 @@ describe('ColorBar', () => {
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
it('runs low to high from the bottom when vertical', () => {
render(
<ColorBar
ramp={['#000', '#fff']}
minLabel="0.18%"
maxLabel="14.2%"
markerPosition={0.25}
orientation="vertical"
/>,
);
const labels = screen.getAllByText(/%$/).map((label) => label.textContent);
expect(labels).toStrictEqual(['14.2%', '0.18%']);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ bottom: '25%' });
});
});

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { ScatterTooltipProps } from '../types';
import TooltipCard from './components/TooltipCard/TooltipCard';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
/** Own component: the shared Tooltip lists one row per series at a shared x. */
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointLabels,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const point = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
[point, channels, decimalPrecision],
);
const labels = useMemo(
() =>
point
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
: [],
[point, resolvePointLabels],
);
if (!point) {
return null;
}
return (
<TooltipCard
title={point.label}
color={point.color}
rows={rows.map((row) => ({ key: row.label, ...row }))}
mutedRows={labels.map((label) => ({
key: label.key,
label: label.key,
value: label.value,
}))}
isPinned={isPinned}
footer={renderTooltipFooter?.({ isPinned, dismiss })}
testId="scatter-tooltip"
/>
);
}

View File

@@ -122,27 +122,6 @@ describe('Tooltip', () => {
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
const withSeries = (series: unknown[]): uPlot =>
Object.assign(createUPlotInstance(0), { series });
const partial = { show: true, partialTimestamps: new Set([1]) };
const { unmount } = renderTooltip({
uPlotInstance: withSeries([{}, partial]),
});
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
'(partial)',
);
unmount();
renderTooltip({
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
});
expect(
screen.queryByTestId('uplot-tooltip-header-partial'),
).not.toBeInTheDocument();
});
it('drops the date from the header title for a point on the current day', () => {
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
const uPlotInstance = createUPlotInstance(0, todayTimestamp);

View File

@@ -0,0 +1,107 @@
import type uPlot from 'uplot';
import {
buildChannelRows,
resolveHoveredPoint,
ScatterHoveredPoint,
} from '../scatterTooltipContent';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
`${value} ${unit ?? ''}`.trim(),
),
}));
const plot = {
data: [
null,
[
[10, 20],
[100, 200],
[5, null],
],
[[30], [300]],
],
series: [
{},
{ label: 'cart', stroke: '#ff0000' },
{ label: 'checkout', stroke: (): string => '#00ff00' },
],
} as unknown as uPlot;
describe('resolveHoveredPoint', () => {
it('reads the focused series at its own data index', () => {
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
seriesIndex: 1,
dataIndex: 1,
label: 'cart',
color: '#ff0000',
x: 20,
y: 200,
size: null,
});
});
it('carries the size column when present and resolves function strokes', () => {
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
label: 'checkout',
color: '#00ff00',
size: null,
});
});
it('is null without a focused series or an index for it', () => {
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
});
});
describe('buildChannelRows', () => {
const point: ScatterHoveredPoint = {
seriesIndex: 1,
dataIndex: 0,
label: 'cart',
color: '#f00',
x: 12,
y: 340,
size: 7,
};
it('formats x and y with their own units', () => {
const rows = buildChannelRows(point, {
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99', unit: 'ms' },
});
expect(rows).toStrictEqual([
{ label: 'Throughput', value: '12 reqps' },
{ label: 'p99', value: '340 ms' },
]);
});
it('adds the size row only when the channel is mapped and the point has one', () => {
const channels = {
x: { label: 'x' },
y: { label: 'y' },
size: { label: 'Errors' },
};
expect(buildChannelRows(point, channels)).toHaveLength(3);
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
});
it('leaves out a size row that repeats the x row', () => {
const rows = buildChannelRows(
{ ...point, size: point.x },
{
x: { label: 'A.count()' },
y: { label: 'p99' },
size: { label: 'A.count()' },
},
);
expect(rows.map((row) => row.label)).toStrictEqual(['A.count()', 'p99']);
});
});

View File

@@ -0,0 +1,103 @@
// Sections own the padding so the footer spans edge to edge.
.container {
// Same inset as the heatmap tooltip.
--gutter: 14px;
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 220px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-6) var(--gutter);
}
// Matches the legend row's marker.
.marker {
box-sizing: border-box;
width: 12px;
height: 12px;
border: 1.5px solid;
border-radius: var(--radius);
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--l1-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 11px;
color: var(--l3-foreground);
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-5) var(--gutter);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-4);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
color: var(--l1-foreground);
}
.rowMuted {
color: var(--l3-foreground);
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,99 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import Styles from './TooltipCard.module.scss';
export interface TooltipCardRow {
key: string;
label: string;
value: string;
}
interface TooltipCardProps {
title: string;
/** The marker beside the title, matching the item's mark on the chart. */
color: string;
/** What the item says. */
rows: TooltipCardRow[];
/** What names the item, below the rows and muted. */
mutedRows?: TooltipCardRow[];
isPinned?: boolean;
footer?: ReactNode;
/** Prefixes the test ids of the title, status, rows and muted rows. */
testId: string;
}
/** One item's tooltip: a marked title, then label/value rows, on the shared tooltip surface. */
export default function TooltipCard({
title,
color,
rows,
mutedRows = [],
isPinned = false,
footer,
testId,
}: TooltipCardProps): JSX.Element {
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid={testId}
>
<div className={Styles.header}>
<span
className={Styles.marker}
style={{ backgroundColor: color, borderColor: color }}
/>
<span
className={Styles.title}
title={title}
data-testid={`${testId}-title`}
>
{title}
</span>
{isPinned && (
<span className={Styles.status} data-testid={`${testId}-status`}>
<Pin size={12} />
<span>Pinned</span>
</span>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div key={row.key} className={Styles.row} data-testid={`${testId}-row`}>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{row.value}</span>
</div>
))}
</div>
{mutedRows.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{mutedRows.map((row) => (
<div
key={row.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid={`${testId}-label`}
>
<span className={Styles.rowLabel} title={row.label}>
{row.label}
</span>
<span className={Styles.rowValue} title={row.value}>
{row.value}
</span>
</div>
))}
</div>
</>
)}
{footer}
</div>
);
}

View File

@@ -13,17 +13,6 @@
gap: var(--spacing-2);
}
.headerEnd {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.partialLabel {
font-style: italic;
opacity: 0.7;
}
.pinnedItem {
padding: var(--spacing-4);
}

View File

@@ -5,7 +5,6 @@ import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import type { ExtendedSeries } from '../../../../config/types';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
@@ -35,7 +34,7 @@ export default function TooltipHeader({
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const header = useMemo(() => {
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
@@ -47,21 +46,14 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return {
title: formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
),
isPartial: (uPlotInstance.series ?? []).some(
(series: ExtendedSeries) =>
series.show && series.partialTimestamps?.has(timestamp),
),
};
return formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.series,
uPlotInstance.cursor.idx,
showTooltipHeader,
dateFormat,
@@ -72,22 +64,10 @@ export default function TooltipHeader({
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && header && (
{showTooltipHeader && headerTitle && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{header.title}</span>
{(header.isPartial || isPinned) && (
<span className={Styles.headerEnd}>
{header.isPartial && (
<span
className={Styles.partialLabel}
data-testid="uplot-tooltip-header-partial"
>
(partial)
</span>
)}
{isPinned && <TooltipPinnedBadge />}
</span>
)}
<span>{headerTitle}</span>
{isPinned && <TooltipPinnedBadge />}
</div>
)}

View File

@@ -0,0 +1,97 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import type uPlot from 'uplot';
import type {
ScatterChannel,
ScatterChannels,
ScatterSeriesData,
} from '../../plugins/ScatterPlugin/types';
import { resolveSeriesColor } from './utils';
export interface ScatterHoveredPoint {
seriesIndex: number;
dataIndex: number;
label: string;
color: string;
x: number;
y: number;
size: number | null;
}
export interface ScatterTooltipRow {
label: string;
value: string;
}
export function resolveHoveredPoint(
u: uPlot,
seriesIndex: number | null,
dataIndexes: Array<number | null>,
): ScatterHoveredPoint | null {
if (seriesIndex == null || seriesIndex < 1) {
return null;
}
const dataIndex = dataIndexes[seriesIndex];
const series = u.series[seriesIndex];
const columns = u.data[seriesIndex] as unknown as
| ScatterSeriesData
| undefined;
if (dataIndex == null || !series || !columns) {
return null;
}
const x = columns[0][dataIndex];
const y = columns[1][dataIndex];
if (x == null || y == null) {
return null;
}
return {
seriesIndex,
dataIndex,
label: String(series.label ?? ''),
color: resolveSeriesColor(series.stroke, u, seriesIndex),
x,
y,
size: columns[2]?.[dataIndex] ?? null,
};
}
function formatChannel(
value: number,
channel: ScatterChannel,
decimalPrecision?: PrecisionOption,
): string {
return getToolTipValue(value, channel.unit, decimalPrecision);
}
export function buildChannelRows(
point: ScatterHoveredPoint,
channels: ScatterChannels,
decimalPrecision?: PrecisionOption,
): ScatterTooltipRow[] {
const rows: ScatterTooltipRow[] = [
{
label: channels.x.label,
value: formatChannel(point.x, channels.x, decimalPrecision),
},
{
label: channels.y.label,
value: formatChannel(point.y, channels.y, decimalPrecision),
},
];
if (channels.size && point.size != null) {
const sizeRow = {
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
};
// Size bound to the X or Y column would only repeat that row.
if (
!rows.some(
(row) => row.label === sizeRow.label && row.value === sizeRow.value,
)
) {
rows.push(sizeRow);
}
}
return rows;
}

View File

@@ -6,6 +6,10 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -119,6 +123,16 @@ export interface HeatmapTooltipProps
groupColor: string;
}
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointLabels?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** Styled like the grid. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid || grid.show === false) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -57,33 +67,43 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/**
* Build values formatter for X-axis (time)
*/
/** Time ticks, or unit-formatted values when a unit/precision is set. */
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
return this.buildValueAxisFormatter();
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
/** Blanks splits outside the scale; uPlot's asinh splits always include ±threshold. */
private buildValueAxisFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return (u, t): string[] => {
const scale = u?.scales?.[scaleKey];
const min = scale?.min ?? -Infinity;
const max = scale?.max ?? Infinity;
return t.map((v) => {
if (
v === null ||
v === undefined ||
Number.isNaN(v) ||
v < min ||
v > max
) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
};
}
/**
@@ -101,7 +121,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -149,10 +169,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
side = 2, // bottom by default
space,
splits,
filter,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const border = this.buildBorderConfig(grid);
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
@@ -177,6 +199,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -189,6 +214,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
if (filter) {
axisConfig.filter = filter;
}
if (size) {
axisConfig.size = size;
}

View File

@@ -1,6 +1,5 @@
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
@@ -20,6 +19,7 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -66,6 +66,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -82,8 +84,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private hasPartialPointsHook = false;
private tzDate: ((timestamp: number) => Date) | undefined;
private id: string;
@@ -163,6 +163,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
setMode(mode: PlotMode): void {
this.mode = mode;
}
/**
* Add or merge a scale configuration
*/
@@ -180,10 +184,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
this.hasPartialPointsHook = true;
this.addHook('draw', partialPointsDrawHook);
}
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
@@ -519,6 +519,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
{} as Record<string, uPlot.Scale>,
);
if (this.mode === PlotMode.Faceted) {
config.mode = this.mode as number as uPlot.Mode;
}
config.hooks = this.hooks;
config.select = this.select;

View File

@@ -40,6 +40,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
range,
thresholds,
logBase = 10,
asinhThreshold,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
@@ -99,6 +100,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -4,7 +4,6 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
import { reportBarRect } from '../utils/barRect';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
@@ -88,6 +87,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (this.props.drawStyle === DrawStyle.Scatter) {
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
@@ -123,7 +124,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
return { paths: (): null => null };
}
@@ -195,6 +196,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
if (drawStyle === DrawStyle.Points) {
return true;
}
// The discs are the series path; uPlot's own points would double-draw them.
if (drawStyle === DrawStyle.Scatter) {
return false;
}
return !!showPoints;
}
@@ -219,14 +224,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const {
scaleKey,
label,
spanGaps,
show = true,
metric,
partialTimestamps,
} = this.props;
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
const resolvedLineColor = this.getLineColor();
@@ -254,7 +252,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
...(facets && { facets }),
};
}
}
@@ -367,7 +365,6 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, effectiveBarMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}
@@ -382,7 +379,6 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, barMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}

View File

@@ -120,6 +120,41 @@ describe('UPlotAxisBuilder', () => {
});
});
it('draws the axis line by default, styled like the grid', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
).getConfig();
expect(config.border).toStrictEqual({
show: true,
stroke: config.grid?.stroke,
width: 0.5,
});
});
it('leaves the axis line off when showBorder is false', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
).not.toHaveProperty('border');
});
it('leaves the axis line off when the grid is hidden', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ grid: { show: false } })).getConfig(),
).not.toHaveProperty('border');
});
it('passes explicit splits and a label filter through to uPlot', () => {
const splits = jest.fn();
const filter = jest.fn();
const config = new UPlotAxisBuilder(
createAxisProps({ splits, filter }),
).getConfig();
expect(config.splits).toBe(splits);
expect(config.filter).toBe(filter);
});
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(
@@ -376,3 +411,51 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBeDefined();
});
});
describe('UPlotAxisBuilder value x axis', () => {
it('formats a non-time x axis with its unit', () => {
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
).getConfig();
const values = (config.values as uPlot.Axis.DynamicValues)(
{} as uPlot,
[1200],
0,
0,
0,
);
expect(values).toStrictEqual(['1.2K req/s']);
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
});
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
).getConfig();
expect(config.values).toBeUndefined();
});
});
describe('UPlotAxisBuilder out-of-range splits', () => {
it('leaves a split the scale cannot place unlabelled', () => {
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
).getConfig();
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
const values = (config.values as uPlot.Axis.DynamicValues)(
u,
[-10, 0, 500, 5000],
0,
0,
0,
);
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
});
});

View File

@@ -5,7 +5,12 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import {
DrawStyle,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -650,20 +655,15 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
const props: SeriesProps = {
scaleKey: 'y',
label: 'Requests',
colorMapping: {},
drawStyle: DrawStyle.Line,
};
const builder = new UPlotConfigBuilder({ id: 'partial' });
builder.addSeries(props);
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
describe('UPlotConfigBuilder plot mode', () => {
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
expect(aligned.getConfig().mode).toBeUndefined();
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

Some files were not shown because too many files have changed in this diff Show More