Compare commits

...

43 Commits

Author SHA1 Message Date
Abhi Kumar
4b1b174dac feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-08 17:52:57 +05:30
Abhi Kumar
6bc1130484 feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-08 17:52:57 +05:30
Abhi Kumar
64728f5468 refactor(dashboard): move scatter plot panel helpers into utils 2026-10-08 17:52:57 +05:30
Abhi Kumar
4be7cb6c97 style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-08 17:52:57 +05:30
Abhi Kumar
9db0f5b0c7 fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-08 17:52:56 +05:30
Abhi Kumar
bdabda9528 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-08 17:52:56 +05:30
Abhi Kumar
064f0bccda feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-08 17:52:56 +05:30
Abhi Kumar
8712b747a4 feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-08 17:52:56 +05:30
Abhi Kumar
c49fad6bc7 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-08 17:52:56 +05:30
Abhi Kumar
45acfc88cc 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-08 17:52:56 +05:30
Abhi Kumar
55fcb854af 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-08 17:52:56 +05:30
Abhi Kumar
04a43b3860 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-08 17:52:56 +05:30
Abhi Kumar
140d39aa36 refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
54baa41199 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-08 17:52:56 +05:30
Abhi Kumar
7107dd6e8c 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-08 17:52:56 +05:30
Abhi Kumar
6a9a330d9c 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-08 17:52:56 +05:30
Abhi Kumar
40f415170d 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-08 17:52:56 +05:30
Abhi Kumar
5270b7c208 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-08 17:52:56 +05:30
Abhi Kumar
0fd6e84df4 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-08 17:52:55 +05:30
Abhi Kumar
4104696846 fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:55 +05:30
Abhi Kumar
95d12f5458 fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:55 +05:30
Abhi Kumar
49f98057eb 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-08 17:52:55 +05:30
Abhi Kumar
c64ef3f3d9 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-08 17:52:55 +05:30
Abhi Kumar
44c807cacc fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:49 +05:30
Abhi Kumar
62107fbb14 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-08 17:52:49 +05:30
Abhi Kumar
526c2c7d29 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-08 17:52:49 +05:30
Abhi Kumar
927b46a5b7 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-08 17:52:49 +05:30
Abhi Kumar
3cbc0f4601 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-08 17:52:49 +05:30
Abhi Kumar
47d38289f4 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-08 17:52:49 +05:30
Abhi Kumar
e334c48735 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-08 17:52:49 +05:30
Abhi Kumar
a7e8a9e19c 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-08 17:52:48 +05:30
Abhi Kumar
dfcad79e79 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-08 17:52:48 +05:30
Abhi Kumar
befac14f47 chore: scatter plot base work 2026-10-08 17:52:48 +05:30
Abhi Kumar
3de4d6eaea feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-08 17:52:05 +05:30
Abhi Kumar
e18afc0178 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
2bac73ee5c 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-08 17:52:05 +05:30
Abhi Kumar
552f35e5bb chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
9eba4a794f 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-08 17:52:05 +05:30
Abhi Kumar
8a0c6c9911 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
d3662e66c7 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Ashwin Bhatkal
04db70bade feat(infra-monitoring): add the overview tab to the kubernetes side drawer (#12948)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds an **Overview** tab to the Kubernetes side drawer. It lists
resources related to the drawer's entity, using the existing v2
infra-monitoring list APIs (`listPods`, `listContainers`, …) and reusing
each category's table columns and fetcher from the entity registry.
- The drawer entity's identity expression (e.g. `k8s.node.name = 'xyz'`)
is locked into every related query as the search's initial expression,
so it cannot be edited away. Anything the user types is added on top.
- A dropdown switches the listed resource (defaults to pods), and a
compass button opens the same list, with the same filter, on the
corresponding k8s list page.
- Clicking a row opens that resource's own drawer, overview tab
included, so you can walk node -> pod -> container.
- Overview is the landing tab for every category that has related
resources; categories without any (volumes, containers) are unchanged
and still land on Metrics.

#### Screen Recording


https://github.com/user-attachments/assets/2bfdf320-0267-4552-b7c2-1eb54451256d

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/512
Closes https://github.com/SigNoz/pulse-pod/issues/513
2026-10-08 08:40:44 +00:00
Abhi kumar
54aaad2cc3 fix(dashboard): heatmap legend search and two-row cap (#13082)
#### Description

- Heatmap legend now shows the search bar and caps at two rows, like
other panels. The layout already reserved the search row, but
`customLegend` only received the item width, so the heatmap never
rendered it and its grid spilled into that space as a third row.
`customLegend` now gets the full `LegendLayout`.
- Legend readouts take an optional `itemLabel` (default `series`);
heatmap passes `groups`, since its entries are summed into one grid
rather than drawn as separate series.

#### Screenshots
Before
<img width="987" height="436" alt="image"
src="https://github.com/user-attachments/assets/f4ec5170-dc11-46c4-8de3-452cbc1fb094"
/>

After
<img width="984" height="429" alt="image"
src="https://github.com/user-attachments/assets/f64aea62-f167-4151-a060-c0e2bf603aa1"
/>


Closes https://github.com/SigNoz/pulse-pod/issues/532
2026-10-08 07:35:06 +00:00
Ashwin Bhatkal
c407456be7 fix(new-select): reveal truncated values and contain dropdown scroll (#13063)
#### Description

- Truncated option labels in `CustomSelect` and `CustomMultiSelect` show
the full value in a tooltip on hover. The tooltip appears only when the
label is actually cut off.
- The selected value in a closed `CustomSelect` shows the same tooltip
when truncated.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/525


#### Screenshots

<img width="242" height="183" alt="Screenshot 2026-10-07 at 2 13 03 PM"
src="https://github.com/user-attachments/assets/e5397b1f-c305-4e9b-902d-fd73af69e3ba"
/>
<img width="375" height="168" alt="Screenshot 2026-10-07 at 3 17 18 PM"
src="https://github.com/user-attachments/assets/8807220a-5822-4198-b31d-787fa22ffb5d"
/>
<img width="616" height="285" alt="Screenshot 2026-10-07 at 2 13 23 PM"
src="https://github.com/user-attachments/assets/fb115f10-8190-4d82-8187-1ba7343ee7a2"
/>
<img width="579" height="481" alt="Screenshot 2026-10-07 at 2 12 49 PM"
src="https://github.com/user-attachments/assets/55c28a24-e3ab-4626-84f5-654c75e3eb96"
/>
2026-10-08 04:25:58 +00:00
213 changed files with 11119 additions and 434 deletions

View File

@@ -3425,6 +3425,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4167,6 +4174,7 @@ components:
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'
@@ -4182,6 +4190,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4195,6 +4204,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4280,6 +4290,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:
@@ -4359,6 +4381,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:
@@ -4532,6 +4564,87 @@ 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/DashboardtypesThresholdWithLabel'
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
DashboardtypesSource:
enum:
- user

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

@@ -4372,6 +4372,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5372,6 +5378,101 @@ 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 interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5382,7 +5483,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6309,6 +6411,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

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,6 +51,7 @@ 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:
return 'scalar';
@@ -278,6 +279,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
};
return (
<div
<TruncatedTooltip
key={option.value || `option-${index}`}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
title={String(option.label || '')}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
</div>
</Checkbox>
</div>
</Checkbox>
</div>
)}
</TruncatedTooltip>
);
},
[

View File

@@ -16,6 +16,7 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const optionId = `option-${index}`;
return (
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
)}
</TruncatedTooltip>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
const optionsWithHighlight = useMemo(() => {
const matching =
options?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
) ?? [];
// antd shows a value with no matching option as a bare string, so keep the
// selected value listed to render it through the tooltip label below.
return (
isEmpty(value)
? matching
: prioritizeOrAddOptionForSingleSelect(matching, value)
).map((option) => ({
...option,
// Rendered as the selected value in the closed control.
label: (
<TruncatedTooltip title={String(option.label || '')} side="bottom">
{(textRef): JSX.Element => (
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
)}
</TruncatedTooltip>
),
}));
}, [options, searchText, value, highlightMatchedText]);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
// Self-provided so label tooltips work wherever this select is rendered.
<TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,4 @@
// Above the antd select popup it is shown from.
.tooltipContent {
--tooltip-z-index: 2100;
}

View File

@@ -0,0 +1,41 @@
import { ReactElement, RefObject, useRef, useState } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import styles from './TruncatedTooltip.module.scss';
interface TruncatedTooltipProps {
title: string;
side?: TooltipSimpleProps['side'];
/** Renders the trigger; attach the ref to the element that may truncate. */
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
}
function TruncatedTooltip({
title,
side = 'right',
children,
}: TruncatedTooltipProps): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean): void => {
const el = textRef.current;
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
};
return (
<TooltipSimple
title={title}
side={side}
arrow
delayDuration={300}
open={open}
onOpenChange={handleOpenChange}
tooltipContentProps={{ className: styles.tooltipContent }}
>
{children(textRef)}
</TooltipSimple>
);
}
export default TruncatedTooltip;

View File

@@ -0,0 +1,34 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
describe('CustomSelect selected value tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it.each([
['listed in the options', [{ label: LABEL, value: LABEL }]],
['missing from the options', [{ label: 'other', value: 'other' }]],
])('reveals a truncated selected value %s on hover', async (_, options) => {
render(<CustomSelect options={options} value={LABEL} />);
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(screen.getByText(LABEL));
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
});

View File

@@ -0,0 +1,63 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import TruncatedTooltip from '../TruncatedTooltip';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
function renderRow(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
render(
<TooltipProvider>
<TruncatedTooltip title={LABEL}>
{(textRef): JSX.Element => (
<div data-testid="row">
<span ref={textRef}>{LABEL}</span>
</div>
)}
</TruncatedTooltip>
</TooltipProvider>,
);
}
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('TruncatedTooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reveals truncated text when its row is hovered', async () => {
renderRow(400, 200);
await hover(screen.getByTestId('row'));
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
it('shows no tooltip when the text fits', async () => {
renderRow(200, 200);
await hover(screen.getByTestId('row'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
value: 'checkout-payment-authorisation',
};
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
export const TruncatedOption: Story = {
args: { options: [LONG_LABEL_OPTION, ...options] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: hovering a cut-off selected value reveals it in full. */
export const TruncatedValue: Story = {
args: {
options: [LONG_LABEL_OPTION, ...options],
value: LONG_LABEL_OPTION.value,
},
play: async (): Promise<void> => {
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: a multi-select option reveals its full label the same way. */
export const MultiTruncatedOption: Story = {
render: (): JSX.Element => (
<CustomMultiSelect
aria-label="Services"
options={[LONG_LABEL_OPTION, ...options]}
/>
),
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Services' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given

View File

@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
background-color: var(--l2-background);
color: color-mix(in srgb, var(--border) 70%, transparent);
}
// The search input overlays the selected value; lift the value above it while
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
&:not(.ant-select-open) .ant-select-selection-item {
position: relative;
z-index: 1;
}
}
// Keep chip styles ONLY in the multi-select
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
}
}
// Ellipsized option label; also the selected value in the closed single select.
.option-label-text {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
.custom-select-dropdown,
.custom-multiselect-dropdown {
position: relative;
overscroll-behavior: contain;
&::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;

View File

@@ -518,7 +518,10 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
placeholder={
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
'Enter limit'
}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>

View File

@@ -43,7 +43,9 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,6 +95,7 @@ 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),
[dataSource, panelType],

View File

@@ -9,6 +9,7 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -169,6 +170,33 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('shows the configured limit placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.SCATTER}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'defaulted',
placeholder: 'Default 10,000',
},
}}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10,000',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -0,0 +1,45 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
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', () => {
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

@@ -57,6 +57,19 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('keeps a defaulted field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -22,8 +22,12 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
*/
export type QueryBuilderFieldRule =
| { state: 'defaulted'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };

View File

@@ -10,6 +10,7 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -20,6 +21,8 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'defaulted':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: 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,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

View File

@@ -5,7 +5,7 @@ import {
InframonitoringtypesHostRecordDTO,
InframonitoringtypesHostStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';

View File

@@ -2,6 +2,13 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
// A block span leaves the icon on the text baseline, a couple of pixels
// above the centre of the row it sits in
[data-slot='icon'] {
display: inline-flex;
align-items: center;
}
}
.infoIcon {

View File

@@ -0,0 +1,78 @@
import { rest } from 'msw';
import { toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
export const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
const POD_NAME = 'app-demo-loadgenerator-579fbdd749-q5kgd';
export const NODE_NAME = 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7';
const POD_META = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': POD_NAME,
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': NODE_NAME,
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const podRecord = {
podUID: POD_UID,
podCPU: 0.92,
podCPURequest: 31,
podCPULimit: 30.7,
podMemory: 148 * 1024 * 1024,
podMemoryRequest: 42,
podMemoryLimit: 38,
podStatus: 'running',
podRestarts: 0,
podAge: 49_000_000,
meta: POD_META,
};
const container = {
containerName: 'loadgenerator',
podName: POD_NAME,
status: 'running',
ready: true,
restarts: 0,
meta: POD_META,
};
const list = (records: unknown[]): Record<string, unknown> => ({
status: 'success',
data: { type: 'list', records, total: records.length },
});
export const drawerMocks = defineStoryMocks({
controls: {
openedFromANode: toggleControl('Opened from a node', {
group: 'Drawer',
value: true,
}),
},
handlers: () => [
rest.post('http://localhost/api/v2/infra_monitoring/pods', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(list([podRecord]))),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
(_req, res, ctx) => res(ctx.status(200), ctx.json(list([container]))),
),
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
],
});

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podEntityConfig } from '../Pods/entity.config';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import K8sBaseDetails from './K8sBaseDetails';
import {
drawerMocks,
NODE_NAME,
POD_UID,
} from './K8sBaseDetails.stories.mocks';
import type { K8sBaseDetailsProps } from './types';
import {
getDrawerHistoryDepth,
pushDrawerHistory,
resetDrawerHistory,
} from './useDrawerHistoryStore';
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
K8sBaseDetailsProps<unknown>;
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
const meta = {
title: 'Infra monitoring/Kubernetes drawer',
component: K8sBaseDetails as (props: DrawerArgs) => JSX.Element,
...storyMocks(drawerMocks, { route }),
decorators: [
(Story, context): JSX.Element => {
// The back control appears only for a resource opened from another
const depth = getDrawerHistoryDepth();
if (context.args.openedFromANode && depth === 0) {
pushDrawerHistory({
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
entity: { nodeName: NODE_NAME },
queryKeyPrefix: 'node',
});
}
if (!context.args.openedFromANode && depth > 0) {
resetDrawerHistory();
}
return <Story />;
},
],
} satisfies Meta<DrawerArgs>;
export default meta;
type Story = StoryObj<DrawerArgs>;
/** A pod opened from a node's overview tab, so the header leads with the way back. */
export const Default: Story = {
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { X } from '@signozhq/icons';
import { ArrowLeft, X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
@@ -16,12 +16,19 @@ import {
useGlobalTimeStore,
} from 'store/globalTime';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8S_CATEGORY_SINGULAR_LABELS,
} from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { CategoryIcon } from './categoryIcons';
import { getEntityNameAttributeKey } from './relations';
import { useDrawerHistoryStore } from './useDrawerHistoryStore';
import { usePrimeEntityDetails } from './usePrimeEntityDetails';
import { K8sBaseDetailsProps } from './types';
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
@@ -86,6 +93,11 @@ export default function K8sBaseDetails<T>({
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const selectedItemExpression = useMemo(
() => getSelectedItemExpression(selectedItemParams),
[getSelectedItemExpression, selectedItemParams],
);
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
@@ -121,9 +133,10 @@ export default function K8sBaseDetails<T>({
const { minTime, maxTime } = getMinMaxTime();
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const expression = getSelectedItemExpression(selectedItemParams);
return fetchEntityData({ filter: { expression }, start, end }, signal);
return fetchEntityData(
{ filter: { expression: selectedItemExpression }, start, end },
signal,
);
},
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
@@ -158,9 +171,29 @@ export default function K8sBaseDetails<T>({
}
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
const previousEntry = useDrawerHistoryStore(
(store) => store.entries[store.entries.length - 1] ?? null,
);
const popDrawerHistory = useDrawerHistoryStore((store) => store.pop);
const primeDetails = usePrimeEntityDetails();
const resetHistory = useDrawerHistoryStore((store) => store.reset);
const handleClose = useCallback((): void => {
resetHistory();
setSelectedItemParams(null);
}, [setSelectedItemParams]);
}, [resetHistory, setSelectedItemParams]);
// Steps back through resources opened from an overview tab
const handleBack = useCallback((): void => {
const previous = popDrawerHistory();
if (!previous) {
return;
}
primeDetails(previous);
setSelectedItemParams(previous.params);
}, [popDrawerHistory, primeDetails, setSelectedItemParams]);
const handleOpenChange = useCallback(
(open: boolean): void => {
@@ -172,11 +205,25 @@ export default function K8sBaseDetails<T>({
);
const handleCopyId = useCallback((): void => {
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}, []);
toast.success(
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
{ position: 'bottom-left' },
);
}, [category]);
const entityName = entity ? getEntityName(entity) : '';
// meta carries the resource's parents; its own name comes from the config
const entityAttributes = useMemo((): Record<string, string> => {
const meta = (entity as { meta?: Record<string, string> | null } | null)
?.meta;
return {
...(meta ?? {}),
...(entityName ? { [getEntityNameAttributeKey(category)]: entityName } : {}),
};
}, [entity, entityName, category]);
useEffect(() => {
if (entity) {
void logEvent(InfraMonitoringEvents.PageVisited, {
@@ -187,20 +234,48 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
// Names the resource it returns to, so a drill several levels deep stays clear
const backTarget = previousEntry
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
: '';
const backLabel = previousEntry ? `${backTarget}: ${previousEntry.label}` : '';
// TODO(H4ad): Improve this on component level
// DrawerWrapper types `title` as string but renders any ReactNode.
const drawerTitle = (
<>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
{/* One control: step back where there is a trail, close otherwise.
Clicking outside the drawer closes it either way. The label is text
rather than a tooltip, which a click on the control would dismiss. */}
{previousEntry ? (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleBack}
data-testid="drawer-back-button"
className={styles.backButton}
aria-label={backLabel}
prefix={<ArrowLeft size={14} />}
>
{backTarget}
</Button>
) : (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
)}
<Divider type="vertical" />
<span className={styles.entityKind} data-testid="drawer-entity-kind">
<CategoryIcon category={category} />
{K8S_CATEGORY_SINGULAR_LABELS[category]}
</span>
<Typography.Text className={styles.title}>
{entityName ||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
@@ -208,8 +283,8 @@ export default function K8sBaseDetails<T>({
'-'}
</Typography.Text>
<CopyButton
value={selectedItem ?? ''}
ariaLabel="Copy ID"
value={entityName || selectedItem || ''}
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
className={styles.copyIdButton}
testId="copy-id-button"
onCopy={handleCopyId}
@@ -271,6 +346,8 @@ export default function K8sBaseDetails<T>({
customTabs={customTabs}
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
eventsInitialExpression={eventsInitialExpression}
entityAttributes={entityAttributes}
entityName={entityName}
/>
</GlobalTimeProvider>
)}

View File

@@ -4,6 +4,7 @@ import {
ChevronsLeftRight,
Compass,
DraftingCompass,
LayoutGrid,
ScrollText,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
@@ -31,6 +32,7 @@ import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityOverview from '../EntityDetailsUtils/EntityOverview/EntityOverview';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
@@ -67,12 +69,16 @@ export default function K8sBaseDetailsContent<T>({
customTabs,
logsAndTracesInitialExpression,
eventsInitialExpression,
entityAttributes,
entityName,
}: K8sBaseDetailsContentProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const tabVisibility = useMemo(
() => ({
// Declared per category, since only a kubernetes resource relates to others
showOverview: false,
showMetrics: true,
showLogs: true,
showTraces: true,
@@ -86,6 +92,9 @@ export default function K8sBaseDetailsContent<T>({
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showOverview) {
tabs.push(VIEW_TYPES.OVERVIEW);
}
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
@@ -104,14 +113,28 @@ export default function K8sBaseDetailsContent<T>({
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
useEffect(() => {
if (
!hideDetailViewTabs &&
selectedView &&
!validTabs.includes(selectedView)
) {
void setSelectedView(defaultView);
}
}, [
hideDetailViewTabs,
selectedView,
validTabs,
defaultView,
setSelectedView,
]);
// An absent view param lands on the first tab the category offers
const effectiveView = hideDetailViewTabs
? VIEW_TYPES.METRICS
: (selectedView ?? defaultView);
// Wait until the view is a valid tab so we don't log a pending value before
// the validation effect above corrects an out-of-scope query param
@@ -169,20 +192,20 @@ export default function K8sBaseDetailsContent<T>({
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
view: effectiveView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
source: 'tab_cta_button',
tab: selectedView,
tab: effectiveView,
sourceKey: null,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
if (selectedView === VIEW_TYPES.LOGS) {
if (effectiveView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
@@ -207,7 +230,7 @@ export default function K8sBaseDetailsContent<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
} else if (effectiveView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
@@ -264,7 +287,7 @@ export default function K8sBaseDetailsContent<T>({
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
entityType={category}
activeTab={selectedView}
activeTab={effectiveView}
/>
)}
</div>
@@ -275,8 +298,21 @@ export default function K8sBaseDetailsContent<T>({
type="single"
className={styles.viewsTabs}
onChange={handleTabChange}
value={selectedView}
value={effectiveView}
items={[
...(tabVisibility.showOverview
? [
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className={styles.viewTitle}>
<LayoutGrid size={14} />
Overview
</div>
),
},
]
: []),
...(tabVisibility.showMetrics
? [
{
@@ -341,7 +377,7 @@ export default function K8sBaseDetailsContent<T>({
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
{effectiveView === VIEW_TYPES.LOGS && (
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -354,7 +390,7 @@ export default function K8sBaseDetailsContent<T>({
</Button>
</TooltipSimple>
)}
{selectedView === VIEW_TYPES.TRACES && (
{effectiveView === VIEW_TYPES.TRACES && (
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -370,6 +406,16 @@ export default function K8sBaseDetailsContent<T>({
</div>
)}
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
<EntityOverview
category={category}
eventEntity={eventCategory}
attributes={entityAttributes}
entity={entity}
entityName={entityName}
queryKeyPrefix={queryKeyPrefix}
/>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
@@ -406,7 +452,7 @@ export default function K8sBaseDetailsContent<T>({
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
effectiveView === tab.key ? (
<Fragment key={tab.key}>
{tab.render({
entity,

View File

@@ -39,6 +39,7 @@ import K8sOptionsSidePanel from './K8sOptionsSidePanel';
import K8sHeader from './K8sHeader';
import { K8sPaginationWarning } from './K8sPaginationWarning';
import K8sTableToolbar from './K8sTableToolbar';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
@@ -301,6 +302,8 @@ export function K8sBaseList<
queryClient.setQueryData(detailQueryKey, { data: record });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
}

View File

@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useInfraMonitoringCategory } from '../hooks';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { getEntityConfig } from './entity.registry';
import { K8sBaseList } from './K8sBaseList';
import K8sBaseDetails from './K8sBaseDetails';
@@ -15,27 +18,35 @@ export function K8sDynamicList({
leftFilters,
}: K8sDynamicListProps): JSX.Element | null {
const [selectedCategory] = useInfraMonitoringCategory();
const [selectedItemParams] = useInfraMonitoringSelectedItemParams();
const config = useMemo(
() => getEntityConfig(selectedCategory),
[selectedCategory],
);
// A row opened from the drawer's overview tab belongs to another category
const drawerCategory = selectedItemParams.category ?? selectedCategory;
const drawerConfig = useMemo(
() => getEntityConfig(drawerCategory),
[drawerCategory],
);
if (!config) {
return null;
}
const { list, details } = config;
return (
<>
<K8sBaseList
{...list}
{...config.list}
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
/>
<K8sBaseDetails {...details} />
{/* No key: remounting would animate the drawer shut and open again
for what is really a change of contents */}
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
</>
);
}

View File

@@ -30,6 +30,7 @@ import {
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
@@ -219,6 +220,8 @@ export function K8sExpandedRow<
queryClient.setQueryData(detailQueryKey, { data: row });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
},
[

View File

@@ -0,0 +1,152 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import {
NuqsTestingAdapter,
type OnUrlUpdateFunction,
} from 'nuqs/adapters/testing';
import { act, render, waitFor } from 'tests/test-utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import K8sBaseDetails from '../K8sBaseDetails';
import { useDrawerHistoryStore } from '../useDrawerHistoryStore';
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
type TestEntity = { name: string };
const mockEntity: TestEntity = { name: 'test-container' };
const NODE_PARAMS = {
selectedItem: 'node-1',
category: InfraMonitoringEntity.NODES,
clusterName: null,
namespaceName: null,
};
/** What the drawer records before opening a resource from the node's overview */
const NODE_ENTRY = {
params: NODE_PARAMS,
category: InfraMonitoringEntity.NODES,
label: 'node-1',
entity: { name: 'node-1' },
queryKeyPrefix: 'node',
};
function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
render(
<NuqsTestingAdapter
searchParams={{
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'container-1',
}}
onUrlUpdate={onUrlUpdate}
>
<K8sBaseDetails<TestEntity>
category={InfraMonitoringEntity.CONTAINERS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
fetchEntityData={jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null })}
getEntityName={(entity): string => entity.name}
getInitialLogTracesExpression={(): string => ''}
getInitialEventsExpression={(): string => ''}
metadataConfig={[
{ label: 'Name', getValue: (entity): string => entity.name },
]}
entityWidgetInfo={[{ title: 'CPU', yAxisUnit: 'percent' }]}
getEntityQueryPayload={jest.fn().mockReturnValue([])}
queryKeyPrefix="testContainer"
/>
</NuqsTestingAdapter>,
);
}
describe('K8sBaseDetails - back navigation', () => {
beforeEach(() => {
useDrawerHistoryStore.getState().reset();
});
it('closes rather than going back when opened from the list', async () => {
act(() => {
renderDrawer();
});
await waitFor(() => {
expect(screen.getAllByText('test-container').length).toBeGreaterThan(0);
});
expect(screen.queryByTestId('drawer-back-button')).not.toBeInTheDocument();
expect(screen.getByTestId('close-drawer-button')).toBeInTheDocument();
});
it('replaces the close control while there is somewhere to go back to', async () => {
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
const backButton = await screen.findByTestId('drawer-back-button');
// The destination is text rather than a tooltip, which a click would dismiss
expect(backButton).toHaveTextContent('Back to Node');
expect(backButton).toHaveAccessibleName('Back to Node: node-1');
expect(screen.queryByTestId('close-drawer-button')).not.toBeInTheDocument();
});
it('returns to the resource this one was opened from', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer(onUrlUpdate);
});
const backButton = await screen.findByTestId('drawer-back-button');
await user.click(backButton);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM) ===
'node-1' &&
searchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY,
) === InfraMonitoringEntity.NODES,
),
).toBe(true);
});
// The trail is one step shorter, so a second back closes instead
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
it('drops the trail when the drawer is dismissed', async () => {
const user = userEvent.setup();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
await screen.findByTestId('drawer-back-button');
// Dismissing from outside the drawer, the way a click on the overlay does
await user.keyboard('{Escape}');
await waitFor(() => {
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
});
});

View File

@@ -21,6 +21,7 @@ type TestEntity = {
name: string;
namespace: string;
cluster: string;
meta?: Record<string, string>;
};
const mockEntity: TestEntity = {
@@ -29,14 +30,22 @@ const mockEntity: TestEntity = {
cluster: 'test-cluster',
};
function createBaseProps() {
/** The overview tab appears only for a resource whose meta relates it to others */
const relatedEntity: TestEntity = {
...mockEntity,
meta: {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.namespace.name': 'default',
'k8s.cluster.name': 'test-cluster',
},
};
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
return {
category: InfraMonitoringEntity.PODS,
category,
eventCategory: InfraMonitoringEvents.Pod,
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null }),
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
getEntityName: (e: TestEntity): string => e.name,
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
@@ -51,7 +60,10 @@ function createBaseProps() {
interface RenderOptions {
view?: string;
category?: InfraMonitoringEntity;
entity?: TestEntity;
tabsConfig?: {
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -67,6 +79,9 @@ interface RenderOptions {
function renderK8sBaseDetails({
view = VIEW_TYPES.METRICS,
// Volumes have no related resources, so they get no overview tab
category = InfraMonitoringEntity.VOLUMES,
entity = mockEntity,
tabsConfig,
customTabs,
}: RenderOptions = {}) {
@@ -78,7 +93,7 @@ function renderK8sBaseDetails({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<K8sBaseDetails<TestEntity>
{...createBaseProps()}
{...createBaseProps(category, entity)}
tabsConfig={tabsConfig}
customTabs={customTabs}
/>
@@ -106,6 +121,25 @@ describe('K8sBaseDetails - Tab Validation', () => {
});
});
it('should land on the overview tab for a category with related resources', async () => {
act(() => {
renderK8sBaseDetails({
view: 'invalid-tab',
category: InfraMonitoringEntity.PODS,
entity: relatedEntity,
tabsConfig: { showOverview: true },
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Overview');
});
});
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
act(() => {
renderK8sBaseDetails({

View File

@@ -0,0 +1,154 @@
import { InfraMonitoringEntity } from '../../constants';
import {
buildRelatedFilterExpression,
carryOverListFilters,
getRelatedCategories,
} from '../relations';
/** What /api/v2/infra_monitoring/pods returns as a pod's meta. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.pod.name': 'checkout-7d9f',
'k8s.namespace.name': 'shop',
'k8s.cluster.name': 'prod',
'k8s.node.name': 'node-1',
'k8s.deployment.name': 'checkout',
};
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
describe('getRelatedCategories', () => {
it('offers every other kubernetes category, never itself', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.PODS, POD_ATTRIBUTES),
).toStrictEqual([
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
]);
});
it('scopes a category it has no relation to by what they share', () => {
// Not this pod's statefulsets, which it has none of: the ones beside it
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.STATEFULSETS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop'");
});
it('falls back to the cluster where that is all two categories share', () => {
// Deployment metrics carry no node, so this relation is unexpressible
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.DEPLOYMENTS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
it('finds nothing for a host, which is not a kubernetes resource', () => {
// Host rows carry os.type and the host name, no kubernetes attribute
expect(
getRelatedCategories(InfraMonitoringEntity.HOSTS, {
'host.name': 'ip-10-0-0-1',
'os.type': 'linux',
}),
).toStrictEqual([]);
});
it('lists pods first, which makes them the default for a node', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.NODES, NODE_ATTRIBUTES)[0],
).toBe(InfraMonitoringEntity.PODS);
});
});
describe('buildRelatedFilterExpression', () => {
it('scopes the pods of a node by the node, inside its cluster', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.PODS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it("scopes a pod's containers by the pod uid, which those rows carry", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.pod.uid = '63edbad0-398e-48e5-baba-f7ded7c73f1b'",
);
});
it("reaches a pod's deployment by name, the only key that endpoint has", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.DEPLOYMENTS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.deployment.name = 'checkout'",
);
});
it('names the parent when the drawer resource belongs to it', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.NODES,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it('asks a cluster list for the cluster alone', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CLUSTERS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
});
describe('carryOverListFilters', () => {
it('keeps the clauses the target endpoint understands', () => {
expect(
carryOverListFilters(
"k8s.namespace.name = 'shop' AND k8s.node.condition_ready = true",
InfraMonitoringEntity.PODS,
),
).toBe("k8s.namespace.name = 'shop'");
});
it('drops a filter written against the source entity only', () => {
expect(
carryOverListFilters('k8s.node.cpu.usage > 0.5', InfraMonitoringEntity.PODS),
).toBe('');
});
it('returns nothing for an empty list filter', () => {
expect(carryOverListFilters(' ', InfraMonitoringEntity.PODS)).toBe('');
});
});

View File

@@ -0,0 +1,48 @@
import type { ComponentProps } from 'react';
import {
ArrowUpDown,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Group,
HardDrive,
Server,
Workflow,
} from '@signozhq/icons';
import { InfraMonitoringEntity } from '../constants';
/**
* One icon per category, so the section tabs and the drawer that opens from
* them show a resource the same way.
*/
type IconSize = ComponentProps<typeof Container>['size'];
const CATEGORY_ICONS: Record<InfraMonitoringEntity, typeof Container> = {
[InfraMonitoringEntity.HOSTS]: Server,
[InfraMonitoringEntity.PODS]: Container,
[InfraMonitoringEntity.CONTAINERS]: Box,
[InfraMonitoringEntity.NODES]: Workflow,
[InfraMonitoringEntity.NAMESPACES]: FilePenLine,
[InfraMonitoringEntity.CLUSTERS]: Boxes,
[InfraMonitoringEntity.DEPLOYMENTS]: Computer,
[InfraMonitoringEntity.STATEFULSETS]: ArrowUpDown,
[InfraMonitoringEntity.DAEMONSETS]: Group,
[InfraMonitoringEntity.JOBS]: Bolt,
[InfraMonitoringEntity.VOLUMES]: HardDrive,
};
export function CategoryIcon({
category,
size = 14,
}: {
category: InfraMonitoringEntity;
size?: IconSize;
}): JSX.Element {
const Icon = CATEGORY_ICONS[category];
return <Icon size={size} />;
}

View File

@@ -3,18 +3,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../../constants';
import { InfraMonitoringEntity } from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../utils';
import styles from './EntityCountsSection.module.scss';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
@@ -55,70 +48,6 @@ export function EntityCountsSection<T>({
closeDrawer();
};
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
};
return (
<div className={styles.countsContainer}>
{countsConfig.map((config) => (
@@ -147,7 +76,7 @@ export function EntityCountsSection<T>({
<TextNoData type="typography" className={styles.countValue} />
)}
<Link
to={buildNavigationUrl(config.targetCategory)}
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
onClick={(): void => handleCardNavigate(config.label)}
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
>

View File

@@ -97,7 +97,11 @@ export function logInfraExplorerNavigatedEvent(params: {
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
source:
| 'chart_compass_icon'
| 'tab_cta_button'
| 'stats_card'
| 'overview_cta';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;

View File

@@ -0,0 +1,265 @@
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
/** The attribute that names one record of a category. */
const PRIMARY_KEY: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.CLUSTERS]: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
[InfraMonitoringEntity.NAMESPACES]:
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
[InfraMonitoringEntity.NODES]: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
[InfraMonitoringEntity.CONTAINERS]:
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
[InfraMonitoringEntity.DEPLOYMENTS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
[InfraMonitoringEntity.STATEFULSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
[InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
[InfraMonitoringEntity.VOLUMES]:
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
[InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
};
/**
* Attributes each list endpoint can be filtered by, broadest first so a built
* expression reads cluster to container. Only keys the category's metrics
* actually carry belong here.
*/
const FILTERABLE_KEYS: Record<InfraMonitoringEntity, string[]> = {
[InfraMonitoringEntity.CLUSTERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
],
[InfraMonitoringEntity.NODES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
],
[InfraMonitoringEntity.NAMESPACES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
],
[InfraMonitoringEntity.DEPLOYMENTS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
],
[InfraMonitoringEntity.STATEFULSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
],
[InfraMonitoringEntity.DAEMONSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
],
[InfraMonitoringEntity.JOBS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
],
[InfraMonitoringEntity.VOLUMES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
],
[InfraMonitoringEntity.PODS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
],
[InfraMonitoringEntity.CONTAINERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
],
[InfraMonitoringEntity.HOSTS]: [],
};
/** Dropdown order: what a resource usually contains, then what contains it. */
const DISPLAY_ORDER: InfraMonitoringEntity[] = [
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
];
export type EntityAttributes = Record<string, string>;
/** The attribute a category's display name belongs under. */
const NAME_KEY: Partial<Record<InfraMonitoringEntity, string>> = {
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
};
export function getEntityNameAttributeKey(
category: InfraMonitoringEntity,
): string {
return NAME_KEY[category] ?? PRIMARY_KEY[category];
}
function matchingKeys(
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string[] {
return FILTERABLE_KEYS[target].filter((key) => !!attributes[key]);
}
/**
* The attribute that ties a target to the drawer's resource: the resource
* itself when the target's records belong to it (the containers of this pod),
* otherwise the target's own name as the resource knows it (the node this pod
* runs on). Null when neither holds, which leaves the target scoped only by
* the ancestors the two share.
*/
function relationKey(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string | null {
if (target === source) {
return null;
}
const keys = matchingKeys(target, attributes);
if (keys.includes(PRIMARY_KEY[source])) {
return PRIMARY_KEY[source];
}
return keys.includes(PRIMARY_KEY[target]) ? PRIMARY_KEY[target] : null;
}
/**
* Every category the drawer's resource can scope a list of, which for a
* kubernetes resource is all of them: where no closer relation exists the two
* still share a cluster. A target is left out only when nothing is shared at
* all, so a list is never asked for unscoped.
*/
export function getRelatedCategories(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
): InfraMonitoringEntity[] {
return DISPLAY_ORDER.filter(
(target) =>
target !== source &&
buildRelatedFilterClauses(source, target, attributes).length > 0,
);
}
/**
* Cluster and namespace ride along with the relation itself, the way other
* observability tools scope these lists: a pod's deployment is read as that
* deployment, in that namespace, in that cluster.
*/
const SCOPE_KEYS = [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
];
export interface RelatedFilterClause {
key: string;
value: string;
}
/**
* Scope clauses, broadest first. The last carries the relation where one
* exists; without one the list is scoped to the shared ancestors, which reads
* as "beside this resource" rather than "belonging to it".
*/
export function buildRelatedFilterClauses(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): RelatedFilterClause[] {
const key = relationKey(source, target, attributes);
const scope = SCOPE_KEYS.filter(
(scopeKey) =>
scopeKey !== key &&
FILTERABLE_KEYS[target].includes(scopeKey) &&
!!attributes[scopeKey],
);
return (key ? [...scope, key] : scope).map((clauseKey) => ({
key: clauseKey,
value: attributes[clauseKey] as string,
}));
}
export function formatRelatedFilterClause(clause: RelatedFilterClause): string {
return `${clause.key} = ${formatValueForExpression(clause.value)}`;
}
export function buildRelatedFilterExpression(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string {
return buildRelatedFilterClauses(source, target, attributes)
.map(formatRelatedFilterClause)
.join(' AND ');
}
export function resolveRelatedCategory(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
requested: string | null,
): InfraMonitoringEntity | null {
const related = getRelatedCategories(source, attributes);
const match = related.find((target) => target === requested);
return match ?? related[0] ?? null;
}
export function carryOverListFilters(
expression: string,
target: InfraMonitoringEntity,
): string {
if (!expression.trim()) {
return '';
}
const supported = new Set(FILTERABLE_KEYS[target]);
return extractQueryPairs(expression)
.filter((pair) => supported.has(pair.key))
.map((pair) => {
const start = pair.position.negationStart ?? pair.position.keyStart;
const lastValue = pair.valuesPosition?.[pair.valuesPosition.length - 1];
const end =
lastValue?.end ?? pair.position.valueEnd ?? pair.position.operatorEnd;
return expression.slice(start, end + 1).trim();
})
.filter(Boolean)
.join(' AND ');
}

View File

@@ -75,6 +75,8 @@ export type GetEntityQueryPayload<T> = (
) => GetQueryResultsProps[];
export interface K8sDetailsTabsConfig {
/** Declared by categories whose resources relate to others; see relations.ts */
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -141,6 +143,10 @@ export interface K8sBaseDetailsContentProps<T> {
customTabs?: K8sDetailsCustomTab<T>[];
logsAndTracesInitialExpression: string;
eventsInitialExpression: string;
/** The drawer entity's own attributes, used to find and scope related resources */
entityAttributes: Record<string, string>;
/** Name of the drawer entity, for the back control of a resource opened from it */
entityName: string;
}
// Aliases for backward compatibility

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
export interface DrawerHistoryEntry {
params: SelectedItemParams;
/** Category of the resource that was open, which the back control names */
category: InfraMonitoringEntity;
/** Name of that resource, shown on hover */
label: string;
/**
* The record the drawer was showing, and the prefix of the query that holds
* it, so stepping back renders from it instead of fetching again.
*/
entity: unknown;
queryKeyPrefix: string;
}
export interface IDrawerHistoryStore {
entries: DrawerHistoryEntry[];
push: (entry: DrawerHistoryEntry) => void;
pop: () => DrawerHistoryEntry | null;
reset: () => void;
}
/**
* The trail left by opening one resource from another's overview tab, so the
* drawer can step back through it instead of only closing.
*/
export const useDrawerHistoryStore = create<IDrawerHistoryStore>()(
(set, get) => ({
entries: [],
push: (entry): void => set({ entries: [...get().entries, entry] }),
pop: (): DrawerHistoryEntry | null => {
const { entries } = get();
const previous = entries[entries.length - 1] ?? null;
if (previous) {
set({ entries: entries.slice(0, -1) });
}
return previous;
},
reset: (): void => set({ entries: [] }),
}),
);
export const resetDrawerHistory = (): void =>
useDrawerHistoryStore.getState().reset();
export const pushDrawerHistory = (entry: DrawerHistoryEntry): void =>
useDrawerHistoryStore.getState().push(entry);
export const getDrawerHistoryDepth = (): number =>
useDrawerHistoryStore.getState().entries.length;

View File

@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useGlobalTimeStore } from 'store/globalTime';
import { SelectedItemParams } from '../hooks';
interface PrimeEntityDetailsParams {
params: SelectedItemParams;
queryKeyPrefix: string;
entity: unknown;
}
/**
* Seeds the drawer's entity query with a record already in hand, so navigating
* between resources renders straight away instead of passing through a loading
* state. The list page does the same on a row click.
*/
export function usePrimeEntityDetails(): (
params: PrimeEntityDetailsParams,
) => void {
const queryClient = useQueryClient();
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(store) => store.getAutoRefreshQueryKey,
);
return useCallback(
({ params, queryKeyPrefix, entity }): void => {
if (!queryKeyPrefix || !entity) {
return;
}
queryClient.setQueryData(
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
),
{ data: entity },
);
},
[queryClient, selectedTime, getAutoRefreshQueryKey],
);
}

View File

@@ -7,7 +7,16 @@ import {
formatValueForExpression,
} from 'components/QueryBuilderV2/utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import {
INFRA_MONITORING_ATTR_KEYS,
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export function sortByColumnOrder<T>(
items: T[],
@@ -178,3 +187,74 @@ export function buildExpressionFromSelectedItemParams(
return clauses.join(' AND ');
}
/**
* Link to the k8s list page for `targetCategory`, pre-filtered by `filterExpression`
* and carrying whichever time range (list or drawer) the current URL holds.
*/
export function buildK8sListNavigationUrl(
targetCategory: InfraMonitoringEntity,
filterExpression: string,
): string {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
}

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -123,6 +123,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
fetchEntityData,

View File

@@ -127,6 +127,7 @@ export const containerEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
fetchEntityData,

View File

@@ -1,4 +1,4 @@
import { Container } from '@signozhq/icons';
import { Box } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerReadyDTO,
@@ -94,7 +94,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
header: (): React.ReactNode => (
<EntityGroupHeader
title="Container Name"
icon={<Container data-hide-expanded="true" size={14} />}
icon={<Box data-hide-expanded="true" size={14} />}
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
/>
),

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -147,6 +147,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
fetchEntityData,

View File

@@ -0,0 +1,166 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
min-width: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.tableHeader {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l1-border);
}
.resourcePicker {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 0 0 auto;
}
.resourceTrigger {
--select-trigger-width: 240px;
--select-trigger-height: 2rem;
}
.resourceOptions {
// The drawer is a dialog at z-index 50 and select content ships without one,
// so without this the dropdown paints behind the drawer.
z-index: 60;
// Radix measures the trigger for us; without this the list is narrower than it
min-width: var(--radix-select-trigger-width);
}
.filterRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
min-width: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
// The row draws the field outline, so the editor inside it draws none
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
}
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filterInput {
flex: 1 1 auto;
min-width: 0;
// The locked scope is already shown as a chip, so drop the editor's own
// scope marker and the room it reserves for it
:global(.query-search-initial-scope-label) {
display: none;
}
:global(
.query-where-clause-editor.hasInitialExpression .cm-editor .cm-content
) {
padding-left: 0 !important;
}
// Its page-width minimum would force the filter row to overflow the drawer
:global(.code-mirror-where-clause .query-where-clause-editor) {
min-width: 0;
}
}
.tableCard {
display: flex;
flex-direction: column;
min-height: 480px;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
min-width: 0;
}
.table {
flex: 1 1 auto;
min-height: 0;
--typography-color: var(--l2-foreground);
}
.noRelations {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
margin-top: var(--spacing-8);
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}

View File

@@ -0,0 +1,76 @@
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
function buildContainers(count: number): unknown[] {
return Array.from({ length: count }, (_, index) => ({
containerName: index === 0 ? 'loadgenerator' : `sidecar-${index}`,
containerID: `containerd://${index}`,
podName: 'app-demo-loadgenerator-579fbdd749-q5kgd',
status: 'running',
ready: true,
restarts: index,
image: 'ghcr.io/open-telemetry/demo:1.14.0-loadgenerator',
cpuUsage: 0.12,
memoryUsage: 148 * 1024 * 1024,
meta: {
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
},
}));
}
export const overviewMocks = defineStoryMocks({
controls: {
containers: countControl('Containers', {
group: 'Overview tab',
value: 12,
max: 40,
}),
},
handlers: (values) => [
// The filter's suggestions ask for these; without them the search box
// logs a failed request and the story's smoke test fails on it
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
async (req, res, ctx) => {
const { offset = 0, limit = 10 } = (await req.json()) as {
offset?: number;
limit?: number;
};
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: buildContainers(values.containers).slice(
offset,
offset + limit,
),
total: values.containers,
},
}),
);
},
),
],
});

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { GlobalTimeProvider } from 'store/globalTime';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import EntityOverview, { EntityOverviewProps } from './EntityOverview';
import { overviewMocks } from './EntityOverview.stories.mocks';
import drawerStyles from '../entityDetails.module.scss';
const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
/** The meta /api/v2/infra_monitoring/pods returns for one pod. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
type OverviewArgs = PageStoryArgs<typeof overviewMocks> & EntityOverviewProps;
const meta = {
title: 'Infra monitoring/Drawer overview tab',
component: EntityOverview,
...storyMocks(overviewMocks, { route }),
decorators: [
(Story): JSX.Element => (
<DrawerWrapper
open
direction="right"
title="app-demo-loadgenerator-579fbdd749-q5kgd"
onOpenChange={(): void => undefined}
className={drawerStyles.entityDetailDrawer}
>
<GlobalTimeProvider>
<Story />
</GlobalTimeProvider>
</DrawerWrapper>
),
],
} satisfies Meta<OverviewArgs>;
export default meta;
type Story = StoryObj<OverviewArgs>;
export const Default: Story = {
args: {
category: InfraMonitoringEntity.PODS,
eventEntity: 'pod',
attributes: POD_ATTRIBUTES,
entity: { podUID: POD_UID },
entityName: POD_ATTRIBUTES['k8s.pod.name'],
queryKeyPrefix: 'pod',
},
};

View File

@@ -0,0 +1,343 @@
import { useCallback, useMemo, useState } from 'react';
import { Compass } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import {
QuerySearchV2Provider,
useExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { validateQuery } from 'utils/queryValidationUtils';
import { getEntityConfig } from '../../Base/entity.registry';
import { logInfraExplorerNavigatedEvent } from '../../Base/events';
import {
buildRelatedFilterClauses,
EntityAttributes,
formatRelatedFilterClause,
RelatedFilterClause,
getEntityNameAttributeKey,
getRelatedCategories,
resolveRelatedCategory,
} from '../../Base/relations';
import { getDrawerDurationMs } from '../../Base/useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../Base/utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8S_CATEGORY_SINGULAR_LABELS,
METRIC_NAMESPACE_BY_ENTITY,
VIEW_TYPES,
} from '../../constants';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import { logInfraDrawerFilterCustomizedEvent } from '../events';
import { RelatedEntitiesTable } from './RelatedEntitiesTable';
import { ScopeChip } from './ScopeChip';
import { useCarriedOverFilters } from './useCarriedOverFilters';
import { useRelatedEntities } from './useRelatedEntities';
import styles from './EntityOverview.module.scss';
export interface EntityOverviewProps {
category: InfraMonitoringEntity;
eventEntity: string;
/** The drawer entity's own attributes, which scope every related query */
attributes: EntityAttributes;
/** The drawer's own record and query prefix, so a row click can leave a
* trail that steps back without fetching again */
entity: unknown;
entityName: string;
queryKeyPrefix: string;
}
interface ContentProps extends EntityOverviewProps {
targetCategory: InfraMonitoringEntity;
lockedExpression: string;
lockedClauses: RelatedFilterClause[];
onCategoryChange: (category: string) => void;
}
function EntityOverviewContent({
category,
eventEntity,
attributes,
entity,
entityName,
queryKeyPrefix,
targetCategory,
lockedExpression,
lockedClauses,
onCategoryChange,
}: ContentProps): JSX.Element | null {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const userExpression = useUserExpression();
const inputExpression = useInputExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const targetConfig = getEntityConfig(targetCategory);
useCarriedOverFilters(targetCategory);
const {
records,
total,
isLoading,
isFetching,
isError,
error,
endTimeBeforeRetention,
setPage,
setLimit,
refetch,
cancel,
} = useRelatedEntities({
fetchListData: targetConfig?.list.fetchListData,
queryKey: `${targetCategory}RelatedEntities`,
expression,
timeRange,
});
// The editor holds only what the user typed; the locked scope is shown beside it
const queryData = useMemo(
(): IBuilderQuery =>
({
aggregateOperator: 'noop',
filter: { expression: userExpression },
}) as IBuilderQuery,
[userExpression],
);
const handleCategoryChange = useCallback(
(value: string | string[]): void => {
if (typeof value === 'string') {
onCategoryChange(value);
}
},
[onCategoryChange],
);
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(lockedExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
combineInitialAndUserExpression(lockedExpression, newUserExpression || ''),
);
if (!validation.isValid) {
return;
}
querySearchOnRun(newUserExpression);
logInfraDrawerFilterCustomizedEvent(
category,
VIEW_TYPES.OVERVIEW,
newUserExpression || '',
'search',
);
refetch();
},
[lockedExpression, inputExpression, querySearchOnRun, category, refetch],
);
const handleNavigate = useCallback((): void => {
logInfraExplorerNavigatedEvent({
entityType: category,
destination: 'k8s_list',
source: 'overview_cta',
tab: VIEW_TYPES.OVERVIEW,
sourceKey: targetCategory,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
openInNewTab(buildK8sListNavigationUrl(targetCategory, expression));
}, [category, targetCategory, expression]);
if (!targetConfig) {
return null;
}
const targetLabel = K8S_CATEGORY_LABELS[targetCategory];
return (
<div className={styles.container}>
<div className={styles.controls}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={VIEW_TYPES.OVERVIEW}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterRow}>
<ScopeChip clauses={lockedClauses} expression={lockedExpression} />
<div className={styles.filterInput}>
<QuerySearch
queryData={queryData}
dataSource={DataSource.METRICS}
onChange={querySearchOnChange}
onRun={handleRunQuery}
signalSource=""
showFilterSuggestionsWithoutMetric
placeholder={`Filter these ${targetLabel.toLowerCase()}`}
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[targetCategory]}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<RelatedEntitiesTable
key={targetCategory}
targetCategory={targetCategory}
header={
<div className={styles.resourcePicker}>
<Typography.Text color="muted" size="small">
Showing
</Typography.Text>
<Select value={targetCategory} onChange={handleCategoryChange}>
<SelectTrigger
className={styles.resourceTrigger}
data-testid="overview-resource-select"
>
<SelectValue>{targetLabel}</SelectValue>
</SelectTrigger>
{/* The drawer sits at z-index 50 and select content ships without one */}
<SelectContent className={styles.resourceOptions}>
{getRelatedCategories(category, attributes).map((relatedCategory) => (
<SelectItem
key={relatedCategory}
value={relatedCategory}
testId={`overview-resource-option-${relatedCategory}`}
>
{K8S_CATEGORY_LABELS[relatedCategory]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipSimple
title={`View on ${targetLabel.toLowerCase()} page`}
side="top"
arrow
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNavigate}
data-testid="overview-view-on-list-page"
prefix={<Compass size={16} />}
/>
</TooltipSimple>
</div>
}
listConfig={targetConfig.list}
records={records}
total={total}
isLoading={isLoading}
isError={isError}
error={error}
endTimeBeforeRetention={endTimeBeforeRetention}
source={{ category, label: entityName, entity, queryKeyPrefix }}
onPageChange={setPage}
onLimitChange={setLimit}
/>
</div>
);
}
export default function EntityOverview(
props: EntityOverviewProps,
): JSX.Element | null {
const { category, attributes } = props;
// Tied to the resource it was picked on, so another drawer starts at its
// own default instead of inheriting the last pick
const [selection, setSelection] = useState<{
identity: string;
category: string;
} | null>(null);
const identity = `${category}:${
attributes[getEntityNameAttributeKey(category)] ?? ''
}`;
const targetCategory = resolveRelatedCategory(
category,
attributes,
selection?.identity === identity ? selection.category : null,
);
const handleCategoryChange = useCallback(
(value: string): void => setSelection({ identity, category: value }),
[identity],
);
const lockedClauses = targetCategory
? buildRelatedFilterClauses(category, targetCategory, attributes)
: [];
const lockedExpression = lockedClauses
.map(formatRelatedFilterClause)
.join(' AND ');
if (!targetCategory) {
return (
<div className={styles.noRelations}>
<Typography.Text color="muted">
{`Nothing related to this ${K8S_CATEGORY_SINGULAR_LABELS[category]} was reported in this time range.`}
</Typography.Text>
</div>
);
}
return (
<QuerySearchV2Provider
queryParamKey={INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION}
initialExpression={lockedExpression}
>
<EntityOverviewContent
{...props}
targetCategory={targetCategory}
lockedExpression={lockedExpression}
lockedClauses={lockedClauses}
onCategoryChange={handleCategoryChange}
/>
</QuerySearchV2Provider>
);
}

View File

@@ -0,0 +1,142 @@
import { ReactNode, useCallback } from 'react';
import cx from 'classnames';
import TanStackTable from 'components/TanStackTableView';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { K8sEmptyState } from '../../Base/K8sEmptyState';
import {
DrawerHistoryEntry,
useDrawerHistoryStore,
} from '../../Base/useDrawerHistoryStore';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from '../../Base/useInfraMonitoringTablePreferencesStore';
import { InfraMonitoringEntity, K8S_CATEGORY_LABELS } from '../../constants';
import {
SelectedItemParams,
useInfraMonitoringSelectedItemParams,
} from '../../hooks';
import { usePrimeEntityDetails } from '../../Base/usePrimeEntityDetails';
import { RELATED_ENTITIES_PAGE_SIZE } from './useRelatedEntities';
import listStyles from '../../Base/K8sBaseList.module.scss';
import styles from './EntityOverview.module.scss';
interface RelatedEntitiesTableProps {
targetCategory: InfraMonitoringEntity;
listConfig: K8sEntityListConfig<K8sEntityData, string | SelectedItemParams>;
records: K8sEntityData[];
total: number;
isLoading: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
/** Rendered in the card's header strip, above the rows it describes */
header: ReactNode;
/** The drawer's own resource, so a row click can leave a trail back to it */
source: Omit<DrawerHistoryEntry, 'params'>;
}
export function RelatedEntitiesTable({
targetCategory,
listConfig,
records,
total,
isLoading,
isError,
error,
endTimeBeforeRetention,
onPageChange,
onLimitChange,
header,
source,
}: RelatedEntitiesTableProps): JSX.Element {
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const pushDrawerHistory = useDrawerHistoryStore((store) => store.push);
const primeDetails = usePrimeEntityDetails();
const lineClamp = useInfraMonitoringLineClamp();
const fontSize = useInfraMonitoringFontSize();
// Opens the clicked resource in its own drawer, overview tab included
const handleRowClick = useCallback(
(record: K8sEntityData, itemKey: string | SelectedItemParams): void => {
const params: SelectedItemParams =
typeof itemKey === 'object'
? itemKey
: { selectedItem: itemKey, clusterName: null, namespaceName: null };
pushDrawerHistory({ ...source, params: selectedItemParams });
// The row already carries the record the drawer is about to fetch
primeDetails({
params,
queryKeyPrefix: listConfig.detailsQueryKeyPrefix,
entity: record,
});
setSelectedItemParams({ ...params, category: targetCategory });
},
[
primeDetails,
pushDrawerHistory,
selectedItemParams,
source,
setSelectedItemParams,
targetCategory,
listConfig.detailsQueryKeyPrefix,
],
);
const isEmpty = !isLoading && records.length === 0;
return (
<div className={styles.tableCard}>
<div className={styles.tableHeader}>{header}</div>
{isEmpty ? (
<div className={styles.emptyState}>
<K8sEmptyState
isError={isError}
error={error}
isLoading={isLoading}
endTimeBeforeRetention={endTimeBeforeRetention}
/>
</div>
) : (
<TanStackTable<K8sEntityData, string | SelectedItemParams>
data={records}
columns={listConfig.tableColumns}
columnStorageKey={`k8s-overview-${targetCategory}-columns`}
isLoading={isLoading}
getRowKey={listConfig.getRowKey}
getItemKey={listConfig.getItemKey}
onRowClick={handleRowClick}
className={cx(listStyles.k8SListTable, styles.table)}
testId={`related-entities-table-${targetCategory}`}
// A page of rows is short enough to render whole, which keeps the card
// at the height of its rows instead of stretching down the drawer
disableVirtualScroll
pagination={{
total,
defaultLimit: RELATED_ENTITIES_PAGE_SIZE,
showTotalCount: true,
totalCountLabel: K8S_CATEGORY_LABELS[targetCategory],
onPageChange,
onLimitChange,
}}
plainTextCellLineClamp={lineClamp}
cellTypographySize={fontSize}
paginationClassname={listStyles.paginationContainer}
resetScrollKey={targetCategory}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Lock } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { RelatedFilterClause } from '../../Base/relations';
import styles from './EntityOverview.module.scss';
interface ScopeChipProps {
clauses: RelatedFilterClause[];
/** The clauses as one expression, shown in full on hover */
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({
clauses,
expression,
}: ScopeChipProps): JSX.Element {
const relation = clauses[clauses.length - 1];
return (
<TooltipSimple title={expression} side="top" arrow>
<span className={styles.scopeChip} data-testid="overview-locked-scope">
<Lock size={12} />
<span className={styles.scopeExpression}>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}> = </span>
<span className={styles.scopeValue}>
{formatValueForExpression(relation.value)}
</span>
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</TooltipSimple>
);
}

View File

@@ -0,0 +1,242 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../../constants';
import EntityOverview from '../EntityOverview';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
const mockQuerySearchProps = jest.fn();
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: (props: {
queryData?: { filter?: { expression?: string } };
initialExpression?: string;
}): JSX.Element => {
mockQuerySearchProps(props);
return <div data-testid="mock-query-search" />;
},
}));
type TestRecord = { name: string; meta?: Record<string, string> | null };
const mockFetchListData = jest.fn();
const mockTableColumns: TableColumnDef<TestRecord>[] = [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
];
jest.mock('../../../Base/entity.registry', () => ({
getEntityConfig: (category: string) => ({
list: {
entity: category,
tableColumns: mockTableColumns,
fetchListData: mockFetchListData,
getRowKey: (record: TestRecord): string => record.name,
getItemKey: (record: TestRecord): string => record.name,
},
}),
}));
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
const LOCKED_EXPRESSION =
"k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'";
interface RenderOptions {
onUrlUpdate?: OnUrlUpdateFunction;
attributes?: Record<string, string>;
}
function overviewTree(
attributes: Record<string, string>,
queryClient: QueryClient,
onUrlUpdate?: OnUrlUpdateFunction,
): JSX.Element {
return (
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<EntityOverview
category={InfraMonitoringEntity.NODES}
eventEntity="node"
attributes={attributes}
entity={{ nodeName: attributes['k8s.node.name'] }}
entityName={attributes['k8s.node.name']}
queryKeyPrefix="node"
/>
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
</QueryClientProvider>
</MemoryRouter>
);
}
function renderOverview({
onUrlUpdate,
attributes = NODE_ATTRIBUTES,
}: RenderOptions = {}): {
openAnotherResource: (next: Record<string, string>) => void;
} {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { rerender } = render(
overviewTree(attributes, queryClient, onUrlUpdate),
);
return {
openAnotherResource: (next): void =>
rerender(overviewTree(next, queryClient, onUrlUpdate)),
};
}
describe('EntityOverview', () => {
beforeEach(() => {
mockQuerySearchProps.mockReset();
mockFetchListData.mockReset();
mockFetchListData.mockResolvedValue({
type: 'list',
records: [{ name: 'pod-1' }],
total: 1,
});
});
it('lists the first related resource scoped by the locked expression', async () => {
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
expect(mockFetchListData.mock.calls[0][0].filter.expression).toBe(
LOCKED_EXPRESSION,
);
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('keeps the locked scope out of the editable filter', async () => {
renderOverview();
// The chip leads with the relation and counts the scope behind it
const chip = await screen.findByTestId('overview-locked-scope');
expect(chip).toHaveTextContent("k8s.node.name = 'node-1'");
expect(chip).toHaveTextContent('+1');
// The editor is handed the user's own expression, never the locked scope
const lastProps = mockQuerySearchProps.mock.calls.at(-1)?.[0];
expect(lastProps.queryData.filter.expression).toBe('');
expect(lastProps.initialExpression).toBe(LOCKED_EXPRESSION);
});
it('opens the clicked resource in its own drawer', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
renderOverview({ onUrlUpdate });
const row = await screen.findByText('pod-1');
await user.click(row);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get('selectedItem') === 'pod-1' &&
searchParams.get('selectedItemCategory') === 'pods',
),
).toBe(true);
});
});
it("forgets the previous drawer's resource when another one opens", async () => {
const user = userEvent.setup();
const { openAnotherResource } = renderOverview();
await user.click(screen.getByTestId('overview-resource-select'));
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
openAnotherResource({ ...NODE_ATTRIBUTES, 'k8s.node.name': 'node-2' });
// Back to pods, this node's default, rather than the containers just picked
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('says so when the resource relates to nothing', async () => {
renderOverview({ attributes: { 'os.type': 'linux' } });
await expect(
screen.findByText(/Nothing related to this Node/),
).resolves.toBeInTheDocument();
expect(mockFetchListData).not.toHaveBeenCalled();
});
it('switches the listed resource from the dropdown', async () => {
const user = userEvent.setup();
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
await user.click(screen.getByTestId('overview-resource-select'));
// Every resource related to a node is offered, the listed one included
await expect(
screen.findByTestId('overview-resource-option-pods'),
).resolves.toBeInTheDocument();
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';
import { useQuerySearchOnRun } from 'components/QueryBuilderV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { parseAsString, useQueryState } from 'nuqs';
import { carryOverListFilters } from '../../Base/relations';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
/**
* Seeds the editable half of the filter with whatever the list page was
* filtered by, reduced to the clauses the listed resource understands. Runs
* once per resource, so it never overwrites what the user types, and stands
* aside for a drawer opened from a link that already carries a filter.
*/
export function useCarriedOverFilters(
targetCategory: InfraMonitoringEntity,
): void {
const { currentQuery } = useQueryBuilder();
const listExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const querySearchOnRun = useQuerySearchOnRun();
const [urlExpression] = useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION,
parseAsString,
);
const seededFor = useRef<string | null>(null);
const hadUrlExpressionOnMount = useRef<boolean | null>(null);
if (hadUrlExpressionOnMount.current === null) {
hadUrlExpressionOnMount.current = !!urlExpression;
}
useEffect(() => {
if (seededFor.current === targetCategory) {
return;
}
const isFirstResource = seededFor.current === null;
seededFor.current = targetCategory;
if (isFirstResource && hadUrlExpressionOnMount.current) {
return;
}
querySearchOnRun(carryOverListFilters(listExpression, targetCategory));
}, [targetCategory, listExpression, querySearchOnRun]);
}

View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { SelectedItemParams } from '../../hooks';
type FetchRelatedEntities = K8sEntityListConfig<
K8sEntityData,
string | SelectedItemParams
>['fetchListData'];
interface UseRelatedEntitiesParams {
/** Absent while the target category is still unresolved; the query stays idle */
fetchListData?: FetchRelatedEntities;
queryKey: string;
expression: string;
/** Time range in seconds — see useEntityDetailsTime */
timeRange: { startTime: number; endTime: number };
}
interface UseRelatedEntitiesResult {
records: K8sEntityData[];
total: number;
page: number;
limit: number;
setPage: (page: number) => void;
setLimit: (limit: number) => void;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
refetch: () => void;
cancel: () => void;
}
export const RELATED_ENTITIES_PAGE_SIZE = 10;
const SECOND_IN_MS = 1000;
export function useRelatedEntities({
fetchListData,
queryKey,
expression,
timeRange,
}: UseRelatedEntitiesParams): UseRelatedEntitiesResult {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(RELATED_ENTITIES_PAGE_SIZE);
useEffect(() => {
setPage(1);
}, [expression, queryKey]);
const reactQueryKey = useMemo(
() => [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
page,
limit,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression, page, limit],
);
const { data, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: ({ signal }) =>
(fetchListData as FetchRelatedEntities)(
{
filter: { expression },
offset: (page - 1) * limit,
limit,
// The list APIs take milliseconds, useEntityDetailsTime gives seconds
start: timeRange.startTime * SECOND_IN_MS,
end: timeRange.endTime * SECOND_IN_MS,
},
signal,
),
enabled: !!fetchListData && !!expression.trim(),
});
const cancel = useCallback((): void => {
void queryClient.cancelQueries({ queryKey: reactQueryKey });
}, [queryClient, reactQueryKey]);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
return {
records: data?.records ?? data?.data ?? [],
total: data?.total ?? 0,
page,
limit,
setPage,
setLimit,
isLoading,
isFetching,
isError,
error: data?.error,
endTimeBeforeRetention: data?.endTimeBeforeRetention,
refetch: handleRefetch,
cancel,
};
}

View File

@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
import { CustomTab } from '../Base/types';
import {
InfraMonitoringEntity,
podUtilizationByPodWidgetInfo,

View File

@@ -26,7 +26,10 @@
overflow-y: auto;
padding: var(--spacing-8);
margin: 0;
min-width: 900px;
// On a 13" screen the drawer itself is narrower than 900px, and a flat
// minimum there clips the right edge of every tab
box-sizing: border-box;
min-width: min(900px, 100%);
}
}
@@ -34,6 +37,29 @@
--button-padding: var(--spacing-2);
}
.backButton {
--button-padding: var(--spacing-2) var(--spacing-3);
flex: none;
white-space: nowrap;
}
// The kind reads as a label on the name, not as part of it
.entityKind {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: none;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.title {
font-family: var(--periscope-font-family-mono);

View File

@@ -1,4 +1,7 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import type {
InfraMonitoringEntity,
VIEWS,
} from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
@@ -15,7 +18,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
tab: `${VIEWS}`,
expression: string,
filterSource: 'search' | 'logs',
): void {

View File

@@ -12,23 +12,11 @@ import {
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
@@ -42,6 +30,7 @@ import {
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
@@ -156,62 +145,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
() => [
{
key: K8sCategories.CONTAINERS,
label: 'Containers',
icon: <Box size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
config: GetContainersQuickFiltersConfig(),
},
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
config: GetPodsQuickFiltersConfig(),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
config: GetNodesQuickFiltersConfig(),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
config: GetNamespaceQuickFiltersConfig(),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
config: GetClustersQuickFiltersConfig(),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
config: GetDeploymentsQuickFiltersConfig(),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
config: GetJobsQuickFiltersConfig(),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
config: GetDaemonsetsQuickFiltersConfig(),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
config: GetStatefulsetsQuickFiltersConfig(),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
config: GetVolumesQuickFiltersConfig(),
},
],

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const jobEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.JOBS,
eventCategory: InfraMonitoringEvents.Job,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'job',
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -143,6 +143,7 @@ export const namespaceEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NAMESPACES,
eventCategory: InfraMonitoringEvents.Namespace,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'namespace',
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
fetchEntityData,

View File

@@ -12,7 +12,7 @@ import {
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';

View File

@@ -129,6 +129,7 @@ export const nodeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NODES,
eventCategory: InfraMonitoringEvents.Node,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'node',
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,

View File

@@ -121,6 +121,7 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
details: {
category: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'pod',
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const statefulSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.STATEFULSETS,
eventCategory: InfraMonitoringEvents.StatefulSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'statefulSet',
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {

View File

@@ -127,6 +127,7 @@ export const volumeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.VOLUMES,
eventCategory: InfraMonitoringEvents.Volume,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'volume',
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -157,9 +157,11 @@ export enum VIEWS {
PROCESSES = 'processes',
EVENTS = 'events',
POD_METRICS = 'pod_metrics',
OVERVIEW = 'overview',
}
export const VIEW_TYPES = {
OVERVIEW: VIEWS.OVERVIEW,
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
@@ -896,6 +898,41 @@ export const getInvalidValueTooltipText = (
attribute: string,
): string => `Some ${entity} do not have ${attribute}s.`;
export const K8S_CATEGORY_LABELS: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.HOSTS]: 'Hosts',
[InfraMonitoringEntity.PODS]: 'Pods',
[InfraMonitoringEntity.NODES]: 'Nodes',
[InfraMonitoringEntity.NAMESPACES]: 'Namespaces',
[InfraMonitoringEntity.CLUSTERS]: 'Clusters',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployments',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSets',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSets',
[InfraMonitoringEntity.CONTAINERS]: 'Containers',
[InfraMonitoringEntity.JOBS]: 'Jobs',
[InfraMonitoringEntity.VOLUMES]: 'Volumes',
};
/**
* Used where one record is named, like the drawer's back control. Cased the
* same way as the plural labels, so StatefulSet keeps its capital.
*/
export const K8S_CATEGORY_SINGULAR_LABELS: Record<
InfraMonitoringEntity,
string
> = {
[InfraMonitoringEntity.HOSTS]: 'Host',
[InfraMonitoringEntity.PODS]: 'Pod',
[InfraMonitoringEntity.NODES]: 'Node',
[InfraMonitoringEntity.NAMESPACES]: 'Namespace',
[InfraMonitoringEntity.CLUSTERS]: 'Cluster',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployment',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSet',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSet',
[InfraMonitoringEntity.CONTAINERS]: 'Container',
[InfraMonitoringEntity.JOBS]: 'Job',
[InfraMonitoringEntity.VOLUMES]: 'Volume',
};
export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
{
[InfraMonitoringEntity.HOSTS]:
@@ -939,9 +976,11 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CATEGORY: 'selectedItemCategory',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
OVERVIEW_EXPRESSION: 'overviewExpression',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',

View File

@@ -18,7 +18,6 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -85,10 +84,13 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
export const useInfraMonitoringView = (): UseQueryStateReturn<
string | null,
undefined
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
parseAsString.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
@@ -137,6 +139,8 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
export interface SelectedItemParams {
selectedItem: string | null;
/** Drawer category, when it differs from the list page category. */
category?: string | null;
clusterName?: string | null;
namespaceName?: string | null;
containerName?: string | null;
@@ -144,6 +148,7 @@ export interface SelectedItemParams {
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
@@ -165,6 +170,8 @@ export const useInfraMonitoringSelectedItemParams =
() => ({
selectedItem:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
category:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY] ?? null,
clusterName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
null,
@@ -183,6 +190,7 @@ export const useInfraMonitoringSelectedItemParams =
if (newParams === null) {
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
@@ -193,6 +201,8 @@ export const useInfraMonitoringSelectedItemParams =
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
newParams.selectedItem ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]:
newParams.category ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,

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,

View File

@@ -14,6 +14,7 @@ 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';
};
@@ -149,6 +150,7 @@ 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.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -24,6 +24,7 @@ export default function Legend({
focusedSeriesIndex,
onAction,
showCopy = true,
itemLabel = 'series',
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [filterQuery, setFilterQuery] = useState('');
@@ -92,11 +93,12 @@ export default function Legend({
position={position}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
itemLabel={itemLabel}
/>
)}
{isEmptyState ? (
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
No {itemLabel} found matching &quot;{effectiveQuery}&quot;
</div>
) : (
<VirtuosoGrid

View File

@@ -14,6 +14,7 @@ export interface LegendToolbarProps {
position: LegendPosition;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
itemLabel: string;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
@@ -23,6 +24,7 @@ export default function LegendToolbar({
position,
filterQuery,
onFilterQueryChange,
itemLabel,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
@@ -48,7 +50,7 @@ export default function LegendToolbar({
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
{`Showing ${visibleCount} of ${totalCount} ${itemLabel}`}
</span>
);

View File

@@ -0,0 +1,105 @@
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
// Padding lives on the sections so a footer can reach the container edges.
.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(--text-vanilla-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 11px;
color: var(--text-vanilla-400);
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(--text-vanilla-100);
}
// The group values name the point; the channels are what it says.
.rowMuted {
color: var(--text-vanilla-400);
}
.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,114 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/**
* One point, its channels, then the group values that name it. Purpose-built
* rather than composed from the shared `Tooltip`, whose list is one row per
* series at a shared x; a scatter point has no such neighbours.
*/
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 (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="scatter-tooltip"
>
<div className={Styles.header}>
<span
className={Styles.marker}
style={{ backgroundColor: point.color, borderColor: point.color }}
/>
<span
className={Styles.title}
title={point.label}
data-testid="scatter-tooltip-title"
>
{point.label}
</span>
{isPinned && (
<span className={Styles.status} data-testid="scatter-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</span>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{row.value}</span>
</div>
))}
</div>
{labels.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{labels.map((label) => (
<div
key={label.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid="scatter-tooltip-label"
>
<span className={Styles.rowLabel} title={label.key}>
{label.key}
</span>
<span className={Styles.rowValue} title={label.value}>
{label.value}
</span>
</div>
))}
</div>
</>
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

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,98 @@
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;
}
/** The point the cursor resolved to: the focused series' own index into its columns. */
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,17 @@ export interface HeatmapTooltipProps
groupColor: string;
}
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
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
@@ -168,6 +183,8 @@ export interface LegendProps {
onAction: OnLegendAction;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Plural noun for the items in the search readouts. Default 'series'. */
itemLabel?: string;
}
/** Props for the uPlot legend controller, which derives items + interaction

View File

@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** The axis line reads as the outermost grid line, so it takes the grid's style. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -58,32 +68,49 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
}
/**
* Build values formatter for X-axis (time)
* Build values formatter for X-axis: time, or a value axis when a unit or
* precision is given (scatter). Neither leaves uPlot's numeric default.
*/
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)
* Build values formatter for a value axis (values with units). A split outside
* the scale's range gets no label: uPlot's arcsinh splits always include
* ±threshold, and it would draw that label past the plot's edge.
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
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 +128,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -149,10 +176,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 +206,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 +221,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

@@ -19,6 +19,7 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -65,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 = {};
@@ -160,6 +163,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
setMode(mode: PlotMode): void {
this.mode = mode;
}
getMode(): PlotMode {
return this.mode;
}
/**
* Add or merge a scale configuration
*/
@@ -512,6 +524,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

@@ -99,6 +99,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold: this.props.asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -87,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) {
@@ -122,7 +124,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
// Scatter without a `pathBuilder` has nothing to draw its discs with.
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
return { paths: (): null => null };
}
@@ -194,6 +197,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;
}
@@ -218,7 +225,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
const resolvedLineColor = this.getLineColor();
@@ -246,6 +253,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(facets && { facets }),
};
}
}

View File

@@ -120,6 +120,35 @@ 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('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 +405,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
@@ -651,3 +656,15 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});
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();
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getMode()).toBe(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

View File

@@ -399,3 +399,39 @@ describe('UPlotSeriesBuilder', () => {
expect(builder.getConfig().fill).toBeUndefined();
});
});
describe('UPlotSeriesBuilder scatter', () => {
it('draws through the given path builder and hides uPlot points', () => {
const pathBuilder = jest.fn();
const config = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Scatter,
pathBuilder,
facets: [{ scale: 'x' }, { scale: 'y' }],
lineColor: '#ff0000',
fillOpacity: 0.5,
lineWidth: 1,
pointSize: 8,
}),
).getConfig();
expect(config.paths).toBe(pathBuilder);
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
expect(config.points?.show).toBe(false);
expect(config.points?.size).toBe(8);
expect(config.stroke).toBe('#ff0000');
expect(config.width).toBe(1);
expect(config.fill).toBe('#ff000080');
});
it('draws nothing without a path builder', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Scatter }),
).getConfig();
expect(
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
).toBeNull();
expect(config.facets).toBeUndefined();
});
});

View File

@@ -78,8 +78,12 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
/** Explicit tick positions, replacing uPlot's per-distribution default. */
splits?: uPlot.Axis.Splits;
/** Which splits get a label, replacing uPlot's per-distribution default. */
filter?: uPlot.Axis.Filter;
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
showBorder?: boolean;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
@@ -90,7 +94,8 @@ export interface AxisProps {
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
@@ -109,6 +114,15 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
* negatives, which a plain log cannot place. */
SymmetricLog = 'symlog',
}
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -125,6 +139,8 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
asinhThreshold?: number;
}
export enum DisconnectedValuesMode {
@@ -146,6 +162,8 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -229,6 +247,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -0,0 +1,114 @@
import type uPlot from 'uplot';
import { Quadtree } from '../../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
} from '../geometry';
import { ScatterHit, ScatterPointSize } from '../types';
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
const asData = (columns: unknown[]): uPlot.AlignedData =>
columns as unknown as uPlot.AlignedData;
describe('resolveSizeDomain', () => {
it('spans the size columns of every series, skipping nulls', () => {
const data = asData([
null,
[
[1, 2],
[1, 2],
[10, null],
],
[[3], [3], [40]],
]);
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
});
it('is null when no series carries sizes', () => {
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
});
});
describe('resolvePointDiameter', () => {
it('uses the fixed diameter without a size or a domain', () => {
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
});
it('maps the domain ends to min and max', () => {
const domain = { min: 0, max: 100 };
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
});
it('scales by area, not diameter', () => {
const midArea = (4 ** 2 + 20 ** 2) / 2;
expect(
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
).toBeCloseTo(Math.sqrt(midArea));
});
it('clamps values outside the domain', () => {
const domain = { min: 10, max: 20 };
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
});
it('uses the midpoint when every size is the same', () => {
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
});
});
describe('resolveHit', () => {
const hit = (
seriesIndex: number,
dataIndex: number,
x: number,
y: number,
d: number,
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
it('returns the disc under the cursor', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
tree.add(hit(2, 3, 50, 50, 6));
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
seriesIndex: 1,
dataIndex: 0,
});
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
seriesIndex: 2,
dataIndex: 3,
});
});
it('is null when the cursor is off every disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
});
it('tolerance widens each disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
});
it('prefers the disc whose centre is nearest when they overlap', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 10));
tree.add(hit(1, 1, 14, 10, 10));
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
});
});

View File

@@ -0,0 +1,288 @@
import uPlot from 'uplot';
import { PlotMode } from '../../../config/types';
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from '../scatterPlugin';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
class FakePath2D {
moveTo = jest.fn();
arc = jest.fn();
}
type OrientCallback = Parameters<typeof uPlot.orient>[2];
interface FakePlotArgs {
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
cursor?: { left: number; top: number };
scaleX?: { min: number; max: number };
scaleY?: { min: number; max: number };
}
/**
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
*/
function createFakePlot({
series,
cursor = { left: -1, top: -1 },
scaleX = { min: 0, max: 100 },
scaleY = { min: 0, max: 100 },
}: FakePlotArgs): uPlot {
const data = [
null,
...series.map((entry) =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return {
data,
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
bbox: { left: 0, top: 0, width: 100, height: 100 },
cursor,
scales: { x: scaleX, y: scaleY },
} as unknown as uPlot;
}
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
function orientWithIdentityScales(
u: uPlot,
seriesIdx: number,
cb: OrientCallback,
): void {
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
if (!columns) {
return;
}
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
const valToPosX = (value: number): number => value;
const valToPosY = (value: number): number => 100 - value;
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
const arc = (path: FakePath2D, ...args: number[]): void => {
path.arc(...args);
};
cb(
u.series[seriesIdx],
columns[0],
columns[1],
scaleX,
scaleY,
valToPosX as unknown as uPlot.ValToPos,
valToPosY as unknown as uPlot.ValToPos,
0,
0,
100,
100,
jest.fn() as never,
jest.fn() as never,
jest.fn() as never,
arc as never,
jest.fn() as never,
);
}
describe('createScatterPlugin', () => {
beforeAll(() => {
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
});
beforeEach(() => {
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
(uPlot.orient as jest.Mock).mockReset();
});
function drawAll(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): void {
plugin.hooks.drawClear(u);
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
const columns = (u.data as unknown as number[][][])[seriesIdx];
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
}
}
/** Runs the cursor scan the way uPlot does: every data series, in order. */
function scan(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): Array<number | null> {
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
const indexes: Array<number | null> = [null];
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
indexes.push(dataIdx(u, seriesIdx, 0, 0));
}
return indexes;
}
it('returns one path that strokes and fills the same discs', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
plugin.hooks.drawClear(u);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect(paths.stroke).toBeInstanceOf(FakePath2D);
expect(paths.fill).toBe(paths.stroke);
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
});
it('resolves the hovered point to its own series and index', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 6, min: 4, max: 20 },
});
const u = createFakePlot({
series: [
{ xs: [10, 50], ys: [10, 50] },
{ xs: [80], ys: [80] },
],
// Over the second series' only point: x 80, y drawn at 100 − 80.
cursor: { left: 80, top: 20 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
});
it('returns null for every series when the cursor is off the plot or off any disc', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: -1, top: -1 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null]);
(u.cursor as { left: number; top: number }).left = 50;
(u.cursor as { left: number; top: number }).top = 50;
expect(scan(u, plugin)).toStrictEqual([null, null]);
});
it('skips points outside the visible scale range', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10, 500], ys: [10, 10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
expect(scan(u, plugin)).toStrictEqual([null, 0]);
});
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 8, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
const bbox = plugin.cursor.points?.bbox;
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
expect(bbox?.(u, 1)).toStrictEqual({
left: 1,
top: 81,
width: 18,
height: 18,
});
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
});
it('snaps the ring to whole pixels without moving its centre', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 7, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
const ring = plugin.cursor.points?.bbox?.(u, 1);
expect(ring).toMatchObject({ left: 1, width: 18 });
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
});
it('draws the hover marker as an outline in the series colour', () => {
const plugin = createScatterPlugin();
const u = {
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
} as unknown as uPlot;
const points = plugin.cursor.points as {
fill: (u: uPlot, seriesIdx: number) => string;
stroke: (u: uPlot, seriesIdx: number) => string;
width: (u: uPlot, seriesIdx: number, size: number) => number;
};
expect(points.fill(u, 1)).toBe('transparent');
expect(points.stroke(u, 1)).toBe('#E5484D');
expect(points.stroke(u, 2)).toBe('#30A46C');
expect(points.width(u, 1, 0)).toBe(2);
});
it('drawClear drops cached paths on data series only', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
xSeries._paths = 'x';
dataSeries._paths = 'cached';
plugin.hooks.drawClear(u);
expect(xSeries._paths).toBe('x');
expect(dataSeries._paths).toBeNull();
});
it('focus distance is zero, so the hit series wins focus', () => {
const plugin = createScatterPlugin();
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
});
});
describe('applyScatterPlugin', () => {
it('switches the builder to faceted mode and disables drag selection', () => {
const builder = new UPlotConfigBuilder({ id: 'scatter' });
const plugin = createScatterPlugin();
applyScatterPlugin(builder, plugin);
const config = builder.getConfig();
expect(builder.getMode()).toBe(PlotMode.Faceted);
expect(config.mode).toBe(2);
expect(config.cursor?.drag).toMatchObject({
x: false,
y: false,
setScale: false,
});
expect(config.hooks?.drawClear).toHaveLength(1);
expect(config.hooks?.destroy).toHaveLength(1);
});
it('facets read x and y against the shared scales', () => {
expect(SCATTER_FACETS).toStrictEqual([
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
]);
});
});

View File

@@ -0,0 +1,96 @@
import type uPlot from 'uplot';
import type { Quadtree } from '../../utils/quadtree';
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
export interface SizeDomain {
min: number;
max: number;
}
/**
* Extent of the size column across every series, so equal values draw equal
* discs whichever group they belong to. `null` when nothing carries a size.
*/
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
let min = Infinity;
let max = -Infinity;
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
if (!sizes) {
continue;
}
for (const size of sizes) {
if (size == null || !Number.isFinite(size)) {
continue;
}
min = Math.min(min, size);
max = Math.max(max, size);
}
}
return min <= max ? { min, max } : null;
}
/**
* Disc diameter for a size value. Area, not diameter, follows the value: a
* point worth twice as much should look twice as big.
*/
export function resolvePointDiameter(
size: number | null | undefined,
domain: SizeDomain | null,
pointSize: ScatterPointSize,
): number {
if (size == null || domain == null || !Number.isFinite(size)) {
return pointSize.fixed;
}
if (domain.max === domain.min) {
return (pointSize.min + pointSize.max) / 2;
}
const t = Math.min(
1,
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
);
const minArea = pointSize.min ** 2;
const maxArea = pointSize.max ** 2;
return Math.sqrt(minArea + (maxArea - minArea) * t);
}
/**
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
* whose centre is closest; `tolerance` widens every disc so thin points stay
* hoverable.
*/
export function resolveHit(
tree: Quadtree<ScatterHit>,
cx: number,
cy: number,
tolerance: number,
): ScatterHit | null {
let best: ScatterHit | null = null;
let bestDistance = Infinity;
tree.get(
cx - tolerance,
cy - tolerance,
tolerance * 2,
tolerance * 2,
(hit) => {
const left = hit.x - tolerance;
const top = hit.y - tolerance;
const right = hit.x + hit.w + tolerance;
const bottom = hit.y + hit.h + tolerance;
if (cx < left || cx > right || cy < top || cy > bottom) {
return;
}
const dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -0,0 +1,234 @@
import uPlot, { Series } from 'uplot';
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
import { PlotMode } from '../../config/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { Quadtree } from '../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
SizeDomain,
} from './geometry';
import {
DEFAULT_HOVER_TOLERANCE_PX,
DEFAULT_SCATTER_POINT_SIZE,
ScatterHit,
ScatterPluginOptions,
ScatterSeriesData,
} from './types';
/** Every scatter series reads its own x and y columns against the shared scales. */
export const SCATTER_FACETS: Series.Facet[] = [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
];
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
const TWO_PI = 2 * Math.PI;
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
const HOVER_RING_GAP_PX = 3;
const HOVER_RING_WIDTH_PX = 2;
/**
* uPlot `ceil`s a cursor point's left/top but not its size, which pulls a ring
* up to 1px right and down of the disc. Snapping the edge to a whole pixel and
* growing the span by twice the snap keeps the centre exact.
*/
function snapToPixel(start: number, length: number): [number, number] {
const snapped = Math.floor(start);
return [snapped, length + (start - snapped) * 2];
}
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
const { stroke } = u.series[seriesIdx];
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
return typeof color === 'string' ? color : '';
}
/** uPlot caches built paths on the series; the field is internal to it. */
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
export interface ScatterPlugin {
/** Draws every point of a series as one path and indexes the discs for hover. */
pathBuilder: Series.PathBuilder;
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
cursor: uPlot.Cursor;
hooks: {
drawClear: (u: uPlot) => void;
destroy: (u: uPlot) => void;
};
getHit: () => ScatterHit | null;
}
export function createScatterPlugin({
pointSize = DEFAULT_SCATTER_POINT_SIZE,
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
}: ScatterPluginOptions = {}): ScatterPlugin {
let tree: Quadtree<ScatterHit> | null = null;
let hit: ScatterHit | null = null;
// The domain spans every series, so it is resolved once per dataset rather than
// once per series path.
let cachedData: uPlot.AlignedData | null = null;
let cachedDomain: SizeDomain | null = null;
function getSizeDomain(u: uPlot): SizeDomain | null {
if (cachedData !== u.data) {
cachedDomain = resolveSizeDomain(u.data);
cachedData = u.data;
}
return cachedDomain;
}
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
const domain = getSizeDomain(u);
const { pxRatio } = uPlot;
uPlot.orient(
u,
seriesIdx,
(
_series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
_moveTo,
_lineTo,
_rect,
arc,
) => {
const xMin = scaleX.min ?? -Infinity;
const xMax = scaleX.max ?? Infinity;
const yMin = scaleY.min ?? -Infinity;
const yMax = scaleY.max ?? Infinity;
for (let i = idx0; i <= idx1; i++) {
const x = dataX[i];
const y = dataY[i];
if (
x == null ||
y == null ||
x < xMin ||
x > xMax ||
y < yMin ||
y > yMax
) {
continue;
}
const diameter =
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
const radius = diameter / 2;
const cx = valToPosX(x, scaleX, xDim, xOff);
const cy = valToPosY(y, scaleY, yDim, yOff);
path.moveTo(cx + radius, cy);
arc(path, cx, cy, radius, 0, TWO_PI);
tree?.add({
x: cx - radius - u.bbox.left,
y: cy - radius - u.bbox.top,
w: diameter,
h: diameter,
seriesIndex: seriesIdx,
dataIndex: i,
});
}
},
);
return { stroke: path, fill: path, clip: null };
};
const cursor: uPlot.Cursor = {
// Selection would set the dashboard time range; neither axis is time here.
drag: { x: false, y: false, setScale: false },
dataIdx: (u, seriesIdx): number | null => {
// uPlot asks series 1..n in order on every cursor move; resolve once.
if (seriesIdx === 1) {
const { left = -1, top = -1 } = u.cursor;
const { pxRatio } = uPlot;
hit =
tree && left >= 0 && top >= 0
? resolveHit(
tree,
left * pxRatio,
top * pxRatio,
hoverTolerance * pxRatio,
)
: null;
}
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
},
points: {
bbox: (_u, seriesIdx): uPlot.BBox => {
if (hit?.seriesIndex !== seriesIdx) {
return HIDDEN_BBOX;
}
const { pxRatio } = uPlot;
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
const [left, width] = snapToPixel(
hit.x / pxRatio - inset,
hit.w / pxRatio + inset * 2,
);
const [top, height] = snapToPixel(
hit.y / pxRatio - inset,
hit.h / pxRatio + inset * 2,
);
return { left, top, width, height };
},
// An outline around the disc, so the point itself stays readable.
fill: (): string => 'transparent',
stroke: getSeriesStroke,
width: (): number => HOVER_RING_WIDTH_PX,
},
// uPlot only measures series that returned a data index, i.e. the hit one.
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
};
return {
pathBuilder,
cursor,
hooks: {
drawClear: (u: uPlot): void => {
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
// The tree only knows what the path builder last drew, so cached paths
// must be rebuilt alongside it.
u.series.forEach((series, index) => {
if (index > 0) {
(series as SeriesWithPaths)._paths = null;
}
});
},
destroy: (): void => {
tree = null;
hit = null;
cachedData = null;
cachedDomain = null;
},
},
getHit: (): ScatterHit | null => hit,
};
}
export function applyScatterPlugin(
builder: UPlotConfigBuilder,
plugin: ScatterPlugin,
): void {
builder.setMode(PlotMode.Faceted);
builder.setCursor(plugin.cursor);
builder.addHook('drawClear', plugin.hooks.drawClear);
builder.addHook('destroy', plugin.hooks.destroy);
}

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