Compare commits

..

3 Commits

Author SHA1 Message Date
srikanthccv
c21e463e55 chore: fix ci 2026-03-01 16:12:16 +05:30
srikanthccv
2091e63195 fix(metrics-explorer): address several bugs in explorer tab 2026-03-01 15:44:30 +05:30
Srikanth Chekuri
95fc905448 chore: move savedview and integration types into their own types package (#10454)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-02-28 12:55:26 +00:00
63 changed files with 1773 additions and 484 deletions

View File

@@ -5459,6 +5459,10 @@ paths:
name: searchText
schema:
type: string
- in: query
name: source
schema:
type: string
responses:
"200":
content:

View File

@@ -3231,6 +3231,11 @@ export type ListMetricsParams = {
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {

View File

@@ -60,11 +60,30 @@
gap: 8px;
margin-left: 108px;
position: relative;
/* Vertical dashed line connecting query elements */
&::after {
content: '';
position: absolute;
left: -28px;
top: 0;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content {
.metrics-aggregation-section-content,
.metrics-container {
position: relative;
&::before {
@@ -102,6 +121,10 @@
.qb-elements-container {
margin-left: 0px;
&::after {
display: none;
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
@@ -333,28 +356,7 @@
text-transform: uppercase;
&::before {
content: '';
height: 120px;
content: '';
position: absolute;
left: 0;
top: 31px;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
left: 15px;
}
&.has-trace-operator {
&::before {
height: 0px;
}
display: none;
}
}
@@ -462,10 +464,21 @@
.qb-content-section {
.qb-elements-container {
&::after {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content {
.metrics-aggregation-section-content,
.metrics-container {
&::before {
border-left: 6px dotted var(--bg-vanilla-300);
}
@@ -529,18 +542,6 @@
.qb-entity-options {
.options {
.query-name {
&::before {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
}
.formula-name {
&::before {
background: repeating-linear-gradient(

View File

@@ -207,6 +207,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}

View File

@@ -1,14 +1,13 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
@@ -44,21 +43,12 @@ export const MetricsSelect = memo(function MetricsSelect({
signalSourceChangeEnabled: boolean;
savePreviousQuery: boolean;
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
const {
updateAllQueriesOperators,
handleSetQueryData,
@@ -164,12 +154,10 @@ export const MetricsSelect = memo(function MetricsSelect({
/>
)}
<AggregatorFilter
onChange={handleAggregatorAttributeChange}
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

View File

@@ -202,8 +202,8 @@ function QueryAddOns({
} else {
filteredAddOns = Object.values(ADD_ONS);
// Filter out group_by for metrics data source
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);

View File

@@ -43,6 +43,7 @@ jest.mock(
);
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: (): JSX.Element => <div />,
MetricNameSelector: (): JSX.Element => <div />,
}));
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');

View File

@@ -177,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -225,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MeterAggregateOperator.COUNT,
timeAggregation: MeterAggregateOperator.AVG,
spaceAggregation: MeterAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},

View File

@@ -441,7 +441,7 @@ describe('Footer utils', () => {
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
timeAggregation: 'avg',
},
],
disabled: false,

View File

@@ -451,9 +451,6 @@ function K8sClustersList({
const handleRowClick = (record: K8sClustersRowData): void => {
if (groupBy.length === 0) {
if (!record.clusterNameRaw) {
return;
}
setSelectedRowData(null);
setselectedClusterName(record.clusterUID);
setSearchParams({
@@ -518,13 +515,9 @@ function K8sClustersList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.clusterNameRaw) {
setselectedClusterName(record.clusterUID);
}
setselectedClusterName(record.clusterUID);
},
className: record.clusterNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -714,10 +707,7 @@ function K8sClustersList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.clusterNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -47,7 +47,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sClustersRowData {
key: string;
clusterUID: string;
clusterNameRaw: string;
clusterName: React.ReactNode;
cpu: React.ReactNode;
memory: React.ReactNode;
@@ -176,7 +175,6 @@ export const formatDataForTable = (
data.map((cluster, index) => ({
key: index.toString(),
clusterUID: cluster.meta.k8s_cluster_name,
clusterNameRaw: cluster.meta.k8s_cluster_name || '',
clusterName: (
<Tooltip title={cluster.meta.k8s_cluster_name}>
{cluster.meta.k8s_cluster_name}

View File

@@ -457,9 +457,6 @@ function K8sDaemonSetsList({
const handleRowClick = (record: K8sDaemonSetsRowData): void => {
if (groupBy.length === 0) {
if (!record.daemonsetNameRaw) {
return;
}
setSelectedRowData(null);
setSelectedDaemonSetUID(record.daemonsetUID);
setSearchParams({
@@ -524,13 +521,9 @@ function K8sDaemonSetsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.daemonsetNameRaw) {
setSelectedDaemonSetUID(record.daemonsetUID);
}
setSelectedDaemonSetUID(record.daemonsetUID);
},
className: record.daemonsetNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -722,10 +715,7 @@ function K8sDaemonSetsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.daemonsetNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -82,7 +82,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sDaemonSetsRowData {
key: string;
daemonsetUID: string;
daemonsetNameRaw: string;
daemonsetName: React.ReactNode;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -277,7 +276,6 @@ export const formatDataForTable = (
data.map((daemonSet, index) => ({
key: index.toString(),
daemonsetUID: daemonSet.daemonSetName,
daemonsetNameRaw: daemonSet.meta.k8s_daemonset_name || '',
daemonsetName: (
<Tooltip title={daemonSet.meta.k8s_daemonset_name}>
{daemonSet.meta.k8s_daemonset_name || ''}

View File

@@ -463,9 +463,6 @@ function K8sDeploymentsList({
const handleRowClick = (record: K8sDeploymentsRowData): void => {
if (groupBy.length === 0) {
if (!record.deploymentNameRaw) {
return;
}
setSelectedRowData(null);
setselectedDeploymentUID(record.deploymentUID);
setSearchParams({
@@ -530,13 +527,9 @@ function K8sDeploymentsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.deploymentNameRaw) {
setselectedDeploymentUID(record.deploymentUID);
}
setselectedDeploymentUID(record.deploymentUID);
},
className: record.deploymentNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -729,10 +722,7 @@ function K8sDeploymentsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.deploymentNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -81,7 +81,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sDeploymentsRowData {
key: string;
deploymentUID: string;
deploymentNameRaw: string;
deploymentName: React.ReactNode;
available_pods: React.ReactNode;
desired_pods: React.ReactNode;
@@ -268,7 +267,6 @@ export const formatDataForTable = (
data.map((deployment, index) => ({
key: index.toString(),
deploymentUID: deployment.meta.k8s_deployment_name,
deploymentNameRaw: deployment.meta.k8s_deployment_name || '',
deploymentName: (
<Tooltip title={deployment.meta.k8s_deployment_name}>
{deployment.meta.k8s_deployment_name}

View File

@@ -337,11 +337,6 @@
cursor: pointer;
}
.disabled-row {
cursor: default;
opacity: 0.6;
}
.k8s-list-table {
.ant-table {
.ant-table-thead > tr > th {

View File

@@ -428,9 +428,6 @@ function K8sJobsList({
const handleRowClick = (record: K8sJobsRowData): void => {
if (groupBy.length === 0) {
if (!record.jobNameRaw) {
return;
}
setSelectedRowData(null);
setselectedJobUID(record.jobUID);
setSearchParams({
@@ -495,11 +492,9 @@ function K8sJobsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.jobNameRaw) {
setselectedJobUID(record.jobUID);
}
setselectedJobUID(record.jobUID);
},
className: record.jobNameRaw ? 'expanded-clickable-row' : 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -689,10 +684,7 @@ function K8sJobsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.jobNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -94,7 +94,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sJobsRowData {
key: string;
jobUID: string;
jobNameRaw: string;
jobName: React.ReactNode;
namespaceName: React.ReactNode;
successful_pods: React.ReactNode;
@@ -304,7 +303,6 @@ export const formatDataForTable = (
data.map((job, index) => ({
key: index.toString(),
jobUID: job.jobName,
jobNameRaw: job.meta.k8s_job_name || '',
jobName: (
<Tooltip title={job.meta.k8s_job_name}>
{job.meta.k8s_job_name || ''}

View File

@@ -459,9 +459,6 @@ function K8sNamespacesList({
const handleRowClick = (record: K8sNamespacesRowData): void => {
if (groupBy.length === 0) {
if (!record.namespaceNameRaw) {
return;
}
setSelectedRowData(null);
setselectedNamespaceUID(record.namespaceUID);
setSearchParams({
@@ -526,13 +523,9 @@ function K8sNamespacesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.namespaceNameRaw) {
setselectedNamespaceUID(record.namespaceUID);
}
setselectedNamespaceUID(record.namespaceUID);
},
className: record.namespaceNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -723,10 +716,7 @@ function K8sNamespacesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.namespaceNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -41,7 +41,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sNamespacesRowData {
key: string;
namespaceUID: string;
namespaceNameRaw: string;
namespaceName: string;
clusterName: string;
cpu: React.ReactNode;
@@ -162,7 +161,6 @@ export const formatDataForTable = (
data.map((namespace, index) => ({
key: index.toString(),
namespaceUID: namespace.namespaceName,
namespaceNameRaw: namespace.namespaceName || '',
namespaceName: namespace.namespaceName,
clusterName: namespace.meta.k8s_cluster_name,
cpu: (

View File

@@ -438,9 +438,6 @@ function K8sNodesList({
const handleRowClick = (record: K8sNodesRowData): void => {
if (groupBy.length === 0) {
if (!record.nodeNameRaw) {
return;
}
setSelectedRowData(null);
setSelectedNodeUID(record.nodeUID);
setSearchParams({
@@ -506,13 +503,9 @@ function K8sNodesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.nodeNameRaw) {
setSelectedNodeUID(record.nodeUID);
}
setSelectedNodeUID(record.nodeUID);
},
className: record.nodeNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -702,10 +695,7 @@ function K8sNodesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.nodeNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -53,7 +53,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sNodesRowData {
key: string;
nodeUID: string;
nodeNameRaw: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
@@ -194,7 +193,6 @@ export const formatDataForTable = (
data.map((node, index) => ({
key: `${node.nodeUID}-${index}`,
nodeUID: node.nodeUID || '',
nodeNameRaw: node.meta.k8s_node_name || '',
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}

View File

@@ -496,9 +496,6 @@ function K8sPodsList({
const handleRowClick = (record: K8sPodsRowData): void => {
if (groupBy.length === 0) {
if (!record.podNameRaw) {
return;
}
setSelectedPodUID(record.podUID);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
@@ -619,11 +616,9 @@ function K8sPodsList({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.podNameRaw) {
setSelectedPodUID(record.podUID);
}
setSelectedPodUID(record.podUID);
},
className: record.podNameRaw ? 'expanded-clickable-row' : 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -758,10 +753,7 @@ function K8sPodsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.podNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -460,9 +460,6 @@ function K8sStatefulSetsList({
const handleRowClick = (record: K8sStatefulSetsRowData): void => {
if (groupBy.length === 0) {
if (!record.statefulsetNameRaw) {
return;
}
setSelectedRowData(null);
setselectedStatefulSetUID(record.statefulsetUID);
setSearchParams({
@@ -527,13 +524,9 @@ function K8sStatefulSetsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.statefulsetNameRaw) {
setselectedStatefulSetUID(record.statefulsetUID);
}
setselectedStatefulSetUID(record.statefulsetUID);
},
className: record.statefulsetNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -725,10 +718,7 @@ function K8sStatefulSetsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.statefulsetNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -82,7 +82,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sStatefulSetsRowData {
key: string;
statefulsetUID: string;
statefulsetNameRaw: string;
statefulsetName: React.ReactNode;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -277,7 +276,6 @@ export const formatDataForTable = (
data.map((statefulSet, index) => ({
key: index.toString(),
statefulsetUID: statefulSet.statefulSetName,
statefulsetNameRaw: statefulSet.meta.k8s_statefulset_name || '',
statefulsetName: (
<Tooltip title={statefulSet.meta.k8s_statefulset_name}>
{statefulSet.meta.k8s_statefulset_name || ''}

View File

@@ -390,9 +390,6 @@ function K8sVolumesList({
const handleRowClick = (record: K8sVolumesRowData): void => {
if (groupBy.length === 0) {
if (!record.volumeNameRaw) {
return;
}
setSelectedRowData(null);
setselectedVolumeUID(record.volumeUID);
setSearchParams({
@@ -457,13 +454,9 @@ function K8sVolumesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.volumeNameRaw) {
setselectedVolumeUID(record.volumeUID);
}
setselectedVolumeUID(record.volumeUID);
},
className: record.volumeNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -648,10 +641,7 @@ function K8sVolumesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.volumeNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -47,7 +47,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sVolumesRowData {
key: string;
volumeUID: string;
volumeNameRaw: string;
pvcName: React.ReactNode;
namespaceName: React.ReactNode;
capacity: React.ReactNode;
@@ -187,7 +186,6 @@ export const formatDataForTable = (
data.map((volume, index) => ({
key: index.toString(),
volumeUID: volume.persistentVolumeClaimName,
volumeNameRaw: volume.persistentVolumeClaimName || '',
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}

View File

@@ -105,7 +105,6 @@ export const defaultAvailableColumns = [
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podNameRaw: string;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -348,7 +347,6 @@ export const formatDataForTable = (
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podNameRaw: pod.meta.k8s_pod_name || '',
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper

View File

@@ -6,6 +6,7 @@ import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
@@ -115,27 +116,34 @@ function TimeSeries(): JSX.Element {
setYAxisUnit(value);
};
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
return (
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="time-series-container">
{responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
{!hasMetricSelected && <EmptyMetricsSearch />}
{hasMetricSelected &&
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,13 +1,21 @@
import { Typography } from 'antd';
import { Empty } from 'antd/lib';
export default function EmptyMetricsSearch(): JSX.Element {
interface EmptyMetricsSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMetricsSearch({
hasQueryResult,
}: EmptyMetricsSearchProps): JSX.Element {
return (
<div className="empty-metrics-search">
<Empty
description={
<Typography.Title level={5}>
Please build and run a valid query to see the result
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>

View File

@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit && unit === units[0]),
units.every((unit) => unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);

View File

@@ -28,6 +28,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import EmptyMetricsSearch from './EmptyMetricsSearch';
import { TimeSeriesProps } from './types';
import {
buildUpdateMetricYAxisUnitPayload,
@@ -209,7 +210,7 @@ function TimeSeries({
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Save the selected unit for this metric?
Set the selected unit as the metric unit?
</Typography.Text>
<Button
type="primary"
@@ -229,64 +230,71 @@ function TimeSeries({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
{metricNames.length === 0 && <EmptyMetricsSearch />}
{metricNames.length > 0 &&
responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const currentYAxisUnit = yAxisUnit || metricUnit;
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
No unit is set for this metric. You can assign one from the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle
size={16}
color={Color.BG_AMBER_400}
role="img"
aria-label="no unit warning"
/>
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import EmptyMetricsSearch from '../EmptyMetricsSearch';
describe('EmptyMetricsSearch', () => {
it('shows select metric message when no query has been run', () => {
render(<EmptyMetricsSearch />);
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
});
it('shows no data message when a query returned empty results', () => {
render(<EmptyMetricsSearch hasQueryResult />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@@ -8,7 +8,7 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
@@ -157,26 +157,6 @@ describe('Explorer', () => {
jest.clearAllMocks();
});
it('should render Explorer query builder with metrics datasource selected', () => {
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
stagedQuery: initialQueriesMap[DataSource.TRACES],
} as any);
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
});
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
@@ -241,20 +221,46 @@ describe('Explorer', () => {
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('should hide y axis unit selector for multiple metrics with different units', () => {
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
metrics: [
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
// One chart per query toggle should be disabled
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeChecked();
expect(oneChartPerQueryToggle).toBeDisabled();
});
@@ -327,4 +333,53 @@ describe('Explorer', () => {
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeEnabled();
});
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
const metricWithNoUnit = {
type: MetrictypesTypeDTO.sum,
description: 'metric without unit',
unit: '',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [metricWithNoUnit, metricWithNoUnit],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
// Toggle should be enabled (not forced/disabled) since both metrics
// have the same unit (no unit) and should be viewable on the same graph
expect(oneChartPerQueryToggle).toBeEnabled();
expect(oneChartPerQueryToggle).not.toBeChecked();
});
});

View File

@@ -1,4 +1,4 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
function renderTimeSeries(
overrides: Partial<TimeSeriesProps> = {},
): RenderResult {
): ReturnType<typeof render> {
return render(
<TimeSeries
showOneChartPerQuery={false}
@@ -84,45 +84,57 @@ describe('TimeSeries', () => {
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('shows select metric message when no metric is selected', () => {
renderTimeSeries({ metricNames: [] });
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
});
it('renders chart view when a metric is selected', () => {
renderTimeSeries({
metricNames: ['metric1'],
metricUnits: ['count'],
metrics: [MOCK_METRIC_METADATA],
});
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
expect(
screen.queryByText('Select a metric and run a query to see the results'),
).not.toBeInTheDocument();
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [undefined, undefined],
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
waitFor(() =>
expect(
screen.findByText('This metric does not have a unit'),
).toBeInTheDocument(),
);
expect(
screen.getByRole('img', { name: 'no unit warning' }),
).toBeInTheDocument();
});
it('clicking on warning icon tooltip should open metric details modal', async () => {
it('warning tooltip shows metric details link', async () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
await user.hover(alertIcon);
const metricDetailsLink = await screen.findByText('metric details');
user.click(metricDetailsLink);
waitFor(() =>
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
);
expect(await screen.findByText('metric details')).toBeInTheDocument();
});
it('shows Save unit button when metric had no unit but one is selected', async () => {
const { findByText, getByRole } = renderTimeSeries({
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
@@ -131,38 +143,10 @@ describe('TimeSeries', () => {
});
expect(
await findByText('Save the selected unit for this metric?'),
await screen.findByText('Set the selected unit as the metric unit?'),
).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' });
expect(yesButton).toBeInTheDocument();
const yesButton = screen.getByRole('button', { name: 'Yes' });
expect(yesButton).toBeEnabled();
});
it('clicking on save unit button shoould upated metric metadata', async () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
const yesButton = getByRole('button', { name: /Yes/i });
await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
pathParams: {
metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});

View File

@@ -139,4 +139,14 @@ describe('getMetricUnits', () => {
expect(result).toHaveLength(1);
expect(result[0]).toBe('s');
});
it('should return undefined for metrics with no unit', () => {
const result = getMetricUnits([
{ ...MOCK_METRIC_METADATA, unit: '' },
{ ...MOCK_METRIC_METADATA, unit: '' },
]);
expect(result).toHaveLength(2);
expect(result[0]).toBeUndefined();
expect(result[1]).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Typography } from 'antd';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -27,7 +27,7 @@ function MetricNameSearch({
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<AggregatorFilter
<MetricNameSelector
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}

View File

@@ -1,8 +1,9 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsService from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
@@ -23,27 +24,31 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
<div data-testid="mock-aggregator-filter">
<input
data-testid="metric-name-input"
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
onChange({ key: e.target.value })
}
/>
<button
type="button"
data-testid="select-metric-button"
onClick={(): void => onSelect({ key: 'test_metric_2' })}
>
Select Metric
</button>
</div>
),
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: jest.fn().mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics: [] } },
}),
useUpdateMetricMetadata: jest.fn().mockReturnValue({
mutate: jest.fn(),
isLoading: false,
}),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}),
);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -123,6 +128,24 @@ describe('QueryBuilder', () => {
it('should call setCurrentMetricName when metric name is selected', async () => {
const user = userEvent.setup();
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
isFetching: false,
isError: false,
data: {
data: {
metrics: [
{
metricName: 'test_metric_2',
type: 'Sum',
isMonotonic: true,
description: '',
temporality: 'cumulative',
unit: '',
},
],
},
},
});
render(
<QueryClientProvider client={queryClient}>
@@ -137,8 +160,12 @@ describe('QueryBuilder', () => {
expect(screen.getByText('From')).toBeInTheDocument();
const selectButton = screen.getByTestId('select-metric-button');
await user.click(selectButton);
const input = within(metricNameSearch).getByRole('combobox');
fireEvent.change(input, { target: { value: 'test_metric_2' } });
const options = document.querySelectorAll('.ant-select-item');
expect(options.length).toBeGreaterThan(0);
await user.click(options[0] as HTMLElement);
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
});

View File

@@ -24,6 +24,7 @@ import {
AggregatorFilter,
GroupByFilter,
HavingFilter,
MetricNameSelector,
OperatorsSelect,
OrderByFilter,
ReduceToFilter,
@@ -403,7 +404,7 @@ export const Query = memo(function Query({
)}
<Col flex="auto">
<AggregatorFilter
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
/>

View File

@@ -0,0 +1,6 @@
.metric-name-selector {
.ant-select-selection-placeholder {
color: var(--bg-slate-200);
font-style: italic;
}
}

View File

@@ -0,0 +1,887 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricNameSelector } from './MetricNameSelector';
const mockUseListMetrics = jest.fn();
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
mockUseListMetrics(...args),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
// while other tests keep the default no-op mock.
const handleSetQueryDataRef: {
current: (index: number, query: IBuilderQuery) => void;
} = {
current: jest.fn(),
};
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
handleSetQueryDataRef.current(index, query),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
panelType: 'TIME_SERIES',
initialDataSource: DataSource.METRICS,
currentQuery: {
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
queryType: 'builder',
},
setLastUsedQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
}),
}));
function makeMetric(
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
): MetricsexplorertypesListMetricDTO {
return {
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
}
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
return {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
timeAggregation: 'avg',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
...overrides,
} as IBuilderQuery;
}
function returnMetrics(
metrics: MetricsexplorertypesListMetricDTO[],
overrides: Record<string, unknown> = {},
): void {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics } },
queryKey: ['/api/v2/metrics'],
...overrides,
});
}
// snippet so tests can assert on them.
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
</div>
);
}
function getOptionLabels(testId: string): string[] {
const list = screen.getByTestId(testId);
const items = within(list).queryAllByRole('listitem');
return items.map((el) => el.textContent || '');
}
describe('MetricNameSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('shows metric names from API as dropdown options', () => {
returnMetrics([
makeMetric({ metricName: 'http_requests_total' }),
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'h' } });
expect(
screen.getAllByText('http_requests_total').length,
).toBeGreaterThanOrEqual(1);
expect(
screen.getAllByText('cpu_usage_percent').length,
).toBeGreaterThanOrEqual(1);
});
it('retains typed metric name in input after blur', () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(input).toHaveValue('http_requests_total');
});
it('shows error message when API request fails', () => {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: true,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'test' } });
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
});
it('shows loading spinner while fetching metrics', () => {
mockUseListMetrics.mockReturnValue({
isFetching: true,
isError: false,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
const { container } = render(
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'test' } });
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
});
});
describe('selecting a metric type updates the aggregation options', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'active_connections' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_seconds',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_seconds' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('ExponentialHistogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_exp',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_exp' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('unknown metric (typed name not in API results) shows all time and space options', () => {
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Max',
'Min',
'Sum',
'Avg',
'Count',
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
});
// these tests require the previous state, so we setup it to
// tracks previousMetricInfo across metric selections
function StatefulMetricQueryHarness({
initialQuery,
}: {
initialQuery: IBuilderQuery;
}): JSX.Element {
const [query, setQuery] = useState(initialQuery);
useEffect(() => {
handleSetQueryDataRef.current = (
_index: number,
newQuery: IBuilderQuery,
): void => {
setQuery(newQuery);
};
return (): void => {
handleSetQueryDataRef.current = jest.fn();
};
}, []);
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<div data-testid="selected-time-agg">
{currentAggregation?.timeAggregation || ''}
</div>
<div data-testid="selected-space-agg">
{currentAggregation?.spaceAggregation || ''}
</div>
<div data-testid="selected-metric-name">
{currentAggregation?.metricName || ''}
</div>
</div>
);
}
describe('switching between metrics of the same type preserves aggregation settings', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
returnMetrics([
makeMetric({
metricName: 'metric_a',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'metric_b',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'metric_a',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'metric_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'metric_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'metric_b',
);
});
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'mem_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'mem_usage' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'mem_usage',
);
});
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'req_duration',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'db_latency',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'req_duration',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'req_duration',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'db_latency' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'db_latency',
);
});
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'exp_hist_a',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
makeMetric({
metricName: 'exp_hist_b',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'exp_hist_a',
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p75',
metricName: 'exp_hist_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'exp_hist_b',
);
});
});
describe('switching to a different metric type resets aggregation to new defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
returnMetrics([
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'sum_metric',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'sum_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'gauge_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'gauge_metric',
);
});
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
returnMetrics([
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'gauge_metric',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'gauge_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'hist_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'hist_metric',
);
});
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
returnMetrics([
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'hist_metric',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'hist_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'sum_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'sum_metric',
);
});
});
describe('typed metric not in search results is committed with unknown defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
// Metric not in search results is committed with empty type resets to unknown defaults
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'unknown_metric',
);
});
});
describe('Summary metric type is treated as Gauge', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('selecting a Summary metric shows Gauge aggregation options', () => {
returnMetrics([
makeMetric({
metricName: 'rpc_duration_summary',
type: MetrictypesTypeDTO.summary,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'rpc_duration_summary' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
});

View File

@@ -0,0 +1,282 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { AutoComplete, Spin, Typography } from 'antd';
import { useListMetrics } from 'api/generated/services/metrics';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';
// N.B on the metric name selector behaviour.
//
// Metric aggregation options resolution:
// The component maintains a ref (metricsRef) of the latest API results.
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
// resolveMetricFromText looks up the metric in metricsRef to determine its type
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
// a name before the debounced search returned), the type is empty and downstream
// treats it as unknown.
//
// Selection handling:
// - Dropdown select: user picks from the dropdown; type is always resolved
// since the option came from the current API results.
// - Blur: user typed a name and tabbed/clicked away without selecting from
// the dropdown. If the name differs from the current metric, it's resolved
// and committed. If the input is empty, it resets to the current metric name.
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
// so the state update is processed synchronously before QueryBuilderV2's
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
// to run before React's root-level event dispatch. However, there is still one
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
//
// Edit mode:
// When a saved query is loaded, the metric name may be set via aggregations
// but aggregateAttribute.type may be missing. Once the API returns metric data,
// the component calls onChange with isEditMode=true to backfill the type without
// resetting aggregation options.
//
// Signal source:
// When signalSource is 'meter', the API is filtered to meter metrics only.
// Changing signalSource clears the input and search text.
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
if (metric.type === MetrictypesTypeDTO.sum && !metric.isMonotonic) {
return ATTRIBUTE_TYPES.GAUGE;
}
const mapping: Record<MetrictypesTypeDTO, ATTRIBUTE_TYPES> = {
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.exponentialhistogram]:
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
};
return mapping[metric.type] || '';
}
function toAutocompleteData(
metricName: string,
type: string,
): BaseAutocompleteData {
return { key: metricName, type, dataType: DataTypes.Float64 };
}
export type MetricNameSelectorProps = {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
disabled?: boolean;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
};
export const MetricNameSelector = memo(function MetricNameSelector({
query,
onChange,
disabled,
defaultValue,
onSelect,
signalSource,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
'';
const [inputValue, setInputValue] = useState<string>(
defaultValue || currentMetricName,
);
const [searchText, setSearchText] = useState<string>(currentMetricName);
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
const selectedFromDropdownRef = useRef(false);
const prevSignalSourceRef = useRef(signalSource);
useEffect(() => {
setInputValue(defaultValue || currentMetricName);
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
prevSignalSourceRef.current = signalSource;
setSearchText('');
setInputValue('');
}
}, [signalSource]);
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const { isFetching, isError, data: listMetricsData } = useListMetrics(
{
searchText: debouncedValue,
limit: 100,
source: signalSource || undefined,
} as Record<string, unknown>,
{
query: {
keepPreviousData: false,
retry: 2,
},
},
);
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
listMetricsData,
]);
useEffect(() => {
metricsRef.current = metrics;
}, [metrics]);
const optionsData = useMemo((): ExtendedSelectOption[] => {
if (!metrics.length) {
return [];
}
return metrics.map((metric) => ({
label: (
<OptionRenderer
label={metric.metricName}
value={metric.metricName}
dataType={DataTypes.Float64}
type={getAttributeType(metric) || ''}
/>
),
value: metric.metricName,
key: metric.metricName,
}));
}, [metrics]);
useEffect(() => {
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
const found = metrics.find((m) => m.metricName === metricName);
if (found) {
onChange(
toAutocompleteData(found.metricName, getAttributeType(found)),
true,
);
}
}
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {
const found = metricsRef.current.find((m) => m.metricName === text);
if (found) {
return toAutocompleteData(found.metricName, getAttributeType(found));
}
return toAutocompleteData(text, '');
},
[],
);
const placeholder = useMemo(() => {
if (signalSource === 'meter') {
return 'Search for a meter metric...';
}
return 'Search for a metric...';
}, [signalSource]);
const handleChange = useCallback((value: string): void => {
setInputValue(value);
}, []);
const handleSearch = useCallback((value: string): void => {
setSearchText(value);
selectedFromDropdownRef.current = false;
}, []);
const handleSelect = useCallback(
(value: string): void => {
selectedFromDropdownRef.current = true;
const resolved = resolveMetricFromText(value);
onChange(resolved);
if (onSelect) {
onSelect(resolved);
}
setSearchText('');
},
[onChange, onSelect, resolveMetricFromText],
);
const handleBlur = useCallback(() => {
if (selectedFromDropdownRef.current) {
selectedFromDropdownRef.current = false;
return;
}
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
onChange(resolveMetricFromText(typedValue));
} else if (!typedValue && currentMetricName) {
setInputValue(currentMetricName);
}
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
flushSync(() => {
onChange(resolveMetricFromText(typedValue));
});
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return (): void => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}
onSearch={handleSearch}
onChange={handleChange}
notFoundContent={
isFetching ? (
<Spin size="small" />
) : isError ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
Failed to load metrics
</Typography.Text>
) : null
}
options={optionsData}
value={inputValue}
onBlur={handleBlur}
onSelect={handleSelect}
disabled={disabled}
/>
);
});

View File

@@ -0,0 +1,2 @@
export type { MetricNameSelectorProps } from './MetricNameSelector';
export { MetricNameSelector } from './MetricNameSelector';

View File

@@ -2,6 +2,7 @@ export { AggregatorFilter } from './AggregatorFilter';
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
export { GroupByFilter } from './GroupByFilter';
export { HavingFilter } from './HavingFilter';
export { MetricNameSelector } from './MetricNameSelector';
export { OperatorsSelect } from './OperatorsSelect';
export { OrderByFilter } from './OrderByFilter';
export { ReduceToFilter } from './ReduceToFilter';

View File

@@ -257,7 +257,9 @@ function TimeSeriesView({
chartData[0]?.length === 0 &&
!isLoading &&
!isError &&
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
dataSource === DataSource.METRICS && (
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
)}
{!isLoading &&
!isError &&

View File

@@ -248,19 +248,12 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
(
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
): void => {
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
const newQuery: IBuilderQuery = {
...query,
aggregateAttribute: value,
};
const getAttributeKeyFromMetricName = (metricName: string): string =>
attributeKeys?.find((key) => key.key === metricName)?.type || '';
if (
newQuery.dataSource === DataSource.METRICS &&
entityVersion === ENTITY_VERSION_V4
@@ -311,9 +304,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Get current metric info
const currentMetricType = newQuery.aggregateAttribute?.type || '';
const prevMetricType = previousMetricInfo?.type
? previousMetricInfo.type
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
const prevMetricType = previousMetricInfo?.type || '';
// Check if metric type has changed by comparing with tracked previous values
const metricTypeChanged =
@@ -374,7 +365,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'rate', space - 'sum', op - 'count'
// If not typed - time - 'avg', space - 'sum'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
@@ -388,7 +379,7 @@ export const useQueryOperations: UseQueryOperations = ({
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
@@ -408,6 +399,29 @@ export const useQueryOperations: UseQueryOperations = ({
];
}
}
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
}
}
}
}

View File

@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -177,7 +177,7 @@ export const replaceVariables = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -267,7 +267,7 @@ export const defaultOutput = {
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
},
],
filter: { expression: '' },
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},

View File

@@ -72,7 +72,6 @@ export type UseQueryOperations = (
handleChangeAggregatorAttribute: (
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
) => void;
handleChangeDataSource: (newSource: DataSource) => void;
handleDeleteQuery: () => void;

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -56,11 +57,81 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
// TODO(srikanthccv): use metadata store to fetch metric metadata
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if params.Source == "meter" {
return m.listMeterMetrics(ctx, params)
}
return m.listMetrics(ctx, orgID, params)
}
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"any(description) AS description",
"any(type) AS metric_type",
"any(unit) AS metric_unit",
"argMax(temporality, unix_milli) AS temporality",
"any(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
if params.Start != nil && params.End != nil {
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
}
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.GroupBy("metric_name")
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
}
defer rows.Close()
metrics := make([]metricsexplorertypes.ListMetric, 0)
for rows.Next() {
var metric metricsexplorertypes.ListMetric
if err := rows.Scan(
&metric.MetricName,
&metric.Description,
&metric.MetricType,
&metric.MetricUnit,
&metric.Temporality,
&metric.IsMonotonic,
); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
}
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -24,7 +25,7 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
var views []types.SavedView
var views []savedviewtypes.SavedView
var err error
if len(category) == 0 {
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
@@ -76,7 +77,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
createBy := claims.Email
updatedBy := claims.Email
dbView := types.SavedView{
dbView := savedviewtypes.SavedView{
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
@@ -105,7 +106,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
var view types.SavedView
var view savedviewtypes.SavedView
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
@@ -146,7 +147,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
updatedBy := claims.Email
_, err = module.sqlstore.BunDB().NewUpdate().
Model(&types.SavedView{}).
Model(&savedviewtypes.SavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
Where("id = ?", uuid.StringValue()).
@@ -160,7 +161,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
_, err := module.sqlstore.BunDB().NewDelete().
Model(&types.SavedView{}).
Model(&savedviewtypes.SavedView{}).
Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -171,7 +172,7 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
savedViews := []*types.SavedView{}
savedViews := []*savedviewtypes.SavedView{}
err := module.
sqlstore.
@@ -184,5 +185,5 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return nil, err
}
return types.NewStatsFromSavedViews(savedViews), nil
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
}

View File

@@ -13,6 +13,7 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -462,7 +463,7 @@ func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
@@ -507,7 +508,7 @@ func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
@@ -279,7 +280,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.WithAdditionalf(err, "cannot delete root user")
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}

View File

@@ -10,15 +10,16 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type cloudProviderAccountsRepository interface {
listConnected(ctx context.Context, orgId string, provider string) ([]types.CloudIntegration, *model.ApiError)
listConnected(ctx context.Context, orgId string, provider string) ([]integrationtypes.CloudIntegration, *model.ApiError)
get(ctx context.Context, orgId string, provider string, id string) (*types.CloudIntegration, *model.ApiError)
get(ctx context.Context, orgId string, provider string, id string) (*integrationtypes.CloudIntegration, *model.ApiError)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*types.CloudIntegration, *model.ApiError)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationtypes.CloudIntegration, *model.ApiError)
// Insert an account or update it by (cloudProvider, id)
// for specified non-empty fields
@@ -27,11 +28,11 @@ type cloudProviderAccountsRepository interface {
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError)
) (*integrationtypes.CloudIntegration, *model.ApiError)
}
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
@@ -48,8 +49,8 @@ type cloudProviderAccountsSQLRepository struct {
func (r *cloudProviderAccountsSQLRepository) listConnected(
ctx context.Context, orgId string, cloudProvider string,
) ([]types.CloudIntegration, *model.ApiError) {
accounts := []types.CloudIntegration{}
) ([]integrationtypes.CloudIntegration, *model.ApiError) {
accounts := []integrationtypes.CloudIntegration{}
err := r.store.BunDB().NewSelect().
Model(&accounts).
@@ -72,8 +73,8 @@ func (r *cloudProviderAccountsSQLRepository) listConnected(
func (r *cloudProviderAccountsSQLRepository) get(
ctx context.Context, orgId string, provider string, id string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -97,8 +98,8 @@ func (r *cloudProviderAccountsSQLRepository) get(
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
ctx context.Context, orgId string, provider string, accountId string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -127,11 +128,11 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError) {
) (*integrationtypes.CloudIntegration, *model.ApiError) {
// Insert
if id == nil {
temp := valuer.GenerateUUID().StringValue()
@@ -181,7 +182,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
)
}
integration := types.CloudIntegration{
integration := integrationtypes.CloudIntegration{
OrgID: orgId,
Provider: provider,
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
@@ -52,7 +53,7 @@ func NewController(sqlStore sqlstore.SQLStore) (*Controller, error) {
}
type ConnectedAccountsListResponse struct {
Accounts []types.Account `json:"accounts"`
Accounts []integrationtypes.Account `json:"accounts"`
}
func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cloudProvider string) (
@@ -67,7 +68,7 @@ func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cl
return nil, model.WrapApiError(apiErr, "couldn't list cloud accounts")
}
connectedAccounts := []types.Account{}
connectedAccounts := []integrationtypes.Account{}
for _, a := range accountRecords {
connectedAccounts = append(connectedAccounts, a.Account())
}
@@ -81,7 +82,7 @@ type GenerateConnectionUrlRequest struct {
// Optional. To be specified for updates.
AccountId *string `json:"account_id,omitempty"`
AccountConfig types.AccountConfig `json:"account_config"`
AccountConfig integrationtypes.AccountConfig `json:"account_config"`
AgentConfig SigNozAgentConfig `json:"agent_config"`
}
@@ -149,9 +150,9 @@ func (c *Controller) GenerateConnectionUrl(ctx context.Context, orgId string, cl
}
type AccountStatusResponse struct {
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status types.AccountStatus `json:"status"`
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
func (c *Controller) GetAccountStatus(ctx context.Context, orgId string, cloudProvider string, accountId string) (
@@ -217,7 +218,7 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
))
}
agentReport := types.AgentReport{
agentReport := integrationtypes.AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
@@ -286,10 +287,10 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
}
type UpdateAccountConfigRequest struct {
Config types.AccountConfig `json:"config"`
Config integrationtypes.AccountConfig `json:"config"`
}
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*types.Account, *model.ApiError) {
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*integrationtypes.Account, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
@@ -306,7 +307,7 @@ func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, clou
return &account, nil
}
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*types.CloudIntegration, *model.ApiError) {
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*integrationtypes.CloudIntegration, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
@@ -346,7 +347,7 @@ func (c *Controller) ListServices(
return nil, model.WrapApiError(apiErr, "couldn't list cloud services")
}
svcConfigs := map[string]*types.CloudServiceConfig{}
svcConfigs := map[string]*integrationtypes.CloudServiceConfig{}
if cloudAccountId != nil {
activeAccount, apiErr := c.accountsRepo.getConnectedCloudAccount(
ctx, orgID, cloudProvider, *cloudAccountId,
@@ -441,8 +442,8 @@ func (c *Controller) GetServiceDetails(
}
type UpdateServiceConfigRequest struct {
CloudAccountId string `json:"cloud_account_id"`
Config types.CloudServiceConfig `json:"config"`
CloudAccountId string `json:"cloud_account_id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
@@ -460,8 +461,8 @@ func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
}
type UpdateServiceConfigResponse struct {
Id string `json:"id"`
Config types.CloudServiceConfig `json:"config"`
Id string `json:"id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (c *Controller) UpdateServiceConfig(

View File

@@ -3,20 +3,20 @@ package cloudintegrations
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
)
type ServiceSummary struct {
services.Metadata
Config *types.CloudServiceConfig `json:"config"`
Config *integrationtypes.CloudServiceConfig `json:"config"`
}
type ServiceDetails struct {
services.Definition
Config *types.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
Config *integrationtypes.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
}
type AccountStatus struct {
@@ -61,7 +61,7 @@ func NewCompiledCollectionStrategy(provider string) (*CompiledCollectionStrategy
// Helper for accumulating strategies for enabled services.
func AddServiceStrategy(serviceType string, cs *CompiledCollectionStrategy,
definitionStrat *services.CollectionStrategy, config *types.CloudServiceConfig) error {
definitionStrat *services.CollectionStrategy, config *integrationtypes.CloudServiceConfig) error {
if definitionStrat.Provider != cs.Provider {
return errors.NewInternalf(CodeMismatchCloudProvider, "can't add %s service strategy to compiled strategy for %s",
definitionStrat.Provider, cs.Provider)

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -18,7 +19,7 @@ type ServiceConfigDatabase interface {
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError)
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
upsert(
ctx context.Context,
@@ -26,15 +27,15 @@ type ServiceConfigDatabase interface {
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError)
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (
configsBySvcId map[string]*types.CloudServiceConfig,
configsBySvcId map[string]*integrationtypes.CloudServiceConfig,
apiErr *model.ApiError,
)
}
@@ -56,9 +57,9 @@ func (r *serviceConfigSQLRepository) get(
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError) {
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
var result types.CloudIntegrationService
var result integrationtypes.CloudIntegrationService
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -89,14 +90,14 @@ func (r *serviceConfigSQLRepository) upsert(
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError) {
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
// get cloud integration id from account id
// if the account is not connected, we don't need to upsert the config
var cloudIntegrationId string
err := r.store.BunDB().NewSelect().
Model((*types.CloudIntegration)(nil)).
Model((*integrationtypes.CloudIntegration)(nil)).
Column("id").
Where("provider = ?", cloudProvider).
Where("account_id = ?", cloudAccountId).
@@ -111,7 +112,7 @@ func (r *serviceConfigSQLRepository) upsert(
))
}
serviceConfig := types.CloudIntegrationService{
serviceConfig := integrationtypes.CloudIntegrationService{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
@@ -139,8 +140,8 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (map[string]*types.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []types.CloudIntegrationService{}
) (map[string]*integrationtypes.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []integrationtypes.CloudIntegrationService{}
err := r.store.BunDB().NewSelect().
Model(&serviceConfigs).
@@ -154,7 +155,7 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
))
}
result := map[string]*types.CloudServiceConfig{}
result := map[string]*integrationtypes.CloudServiceConfig{}
for _, r := range serviceConfigs {
result[r.Type] = &r.Config

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -107,7 +108,7 @@ type IntegrationsListItem struct {
type Integration struct {
IntegrationDetails
Installation *types.InstalledIntegration `json:"installation"`
Installation *integrationtypes.InstalledIntegration `json:"installation"`
}
type Manager struct {
@@ -223,7 +224,7 @@ func (m *Manager) InstallIntegration(
ctx context.Context,
orgId string,
integrationId string,
config types.InstalledIntegrationConfig,
config integrationtypes.InstalledIntegrationConfig,
) (*IntegrationsListItem, *model.ApiError) {
integrationDetails, apiErr := m.getIntegrationDetails(ctx, integrationId)
if apiErr != nil {
@@ -429,7 +430,7 @@ func (m *Manager) getInstalledIntegration(
ctx context.Context,
orgId string,
integrationId string,
) (*types.InstalledIntegration, *model.ApiError) {
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
iis, apiErr := m.installedIntegrationsRepo.get(
ctx, orgId, []string{integrationId},
)
@@ -457,7 +458,7 @@ func (m *Manager) getInstalledIntegrations(
return nil, apiErr
}
installedTypes := utils.MapSlice(installations, func(i types.InstalledIntegration) string {
installedTypes := utils.MapSlice(installations, func(i integrationtypes.InstalledIntegration) string {
return i.Type
})
integrationDetails, apiErr := m.availableIntegrationsRepo.get(ctx, installedTypes)

View File

@@ -4,22 +4,22 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
)
type InstalledIntegrationsRepo interface {
list(ctx context.Context, orgId string) ([]types.InstalledIntegration, *model.ApiError)
list(ctx context.Context, orgId string) ([]integrationtypes.InstalledIntegration, *model.ApiError)
get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError)
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError)
upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError)
config integrationtypes.InstalledIntegrationConfig,
) (*integrationtypes.InstalledIntegration, *model.ApiError)
delete(ctx context.Context, orgId string, integrationType string) *model.ApiError
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -26,8 +27,8 @@ func NewInstalledIntegrationsSqliteRepo(store sqlstore.SQLStore) (
func (r *InstalledIntegrationsSqliteRepo) list(
ctx context.Context,
orgId string,
) ([]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) ([]integrationtypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationtypes.InstalledIntegration{}
err := r.store.BunDB().NewSelect().
Model(&integrations).
@@ -44,8 +45,8 @@ func (r *InstalledIntegrationsSqliteRepo) list(
func (r *InstalledIntegrationsSqliteRepo) get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationtypes.InstalledIntegration{}
typeValues := []interface{}{}
for _, integrationType := range integrationTypes {
@@ -62,7 +63,7 @@ func (r *InstalledIntegrationsSqliteRepo) get(
))
}
result := map[string]types.InstalledIntegration{}
result := map[string]integrationtypes.InstalledIntegration{}
for _, ii := range integrations {
result[ii.Type] = ii
}
@@ -74,10 +75,10 @@ func (r *InstalledIntegrationsSqliteRepo) upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError) {
config integrationtypes.InstalledIntegrationConfig,
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
integration := types.InstalledIntegration{
integration := integrationtypes.InstalledIntegration{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -114,7 +115,7 @@ func (r *InstalledIntegrationsSqliteRepo) delete(
ctx context.Context, orgId string, integrationType string,
) *model.ApiError {
_, dbErr := r.store.BunDB().NewDelete().
Model(&types.InstalledIntegration{}).
Model(&integrationtypes.InstalledIntegration{}).
Where("type = ?", integrationType).
Where("org_id = ?", orgId).
Exec(ctx)

View File

@@ -1631,8 +1631,10 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
if err != nil {
return nil, nil, err
}
// TODO: return error after table migration are run
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
if err != nil {
return nil, nil, err
}
// For metrics not found in the database, set to Unknown
for _, metricName := range metricNames {
@@ -1728,6 +1730,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
"metric_name",
"argMax(temporality, unix_milli) as temporality",
"any(type) AS type",
"any(is_monotonic) as is_monotonic",
).From(t.meterDBName + "." + t.meterFieldsTblName)
// Filter by metric names (in the temporality column due to data mix-up)

View File

@@ -1,4 +1,4 @@
package types
package integrationtypes
import (
"database/sql/driver"
@@ -6,6 +6,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/uptrace/bun"
)
@@ -26,17 +27,17 @@ var AllIntegrationUserEmails = []IntegrationUserEmail{
type InstalledIntegration struct {
bun.BaseModel `bun:"table:installed_integration"`
Identifiable
types.Identifiable
Type string `json:"type" bun:"type,type:text,unique:org_id_type"`
Config InstalledIntegrationConfig `json:"config" bun:"config,type:text"`
InstalledAt time.Time `json:"installed_at" bun:"installed_at,default:current_timestamp"`
OrgID string `json:"org_id" bun:"org_id,type:text,unique:org_id_type,references:organizations(id),on_delete:cascade"`
}
type InstalledIntegrationConfig map[string]interface{}
type InstalledIntegrationConfig map[string]any
// For serializing from db
func (c *InstalledIntegrationConfig) Scan(src interface{}) error {
func (c *InstalledIntegrationConfig) Scan(src any) error {
var data []byte
switch v := src.(type) {
case []byte:
@@ -67,8 +68,8 @@ func (c *InstalledIntegrationConfig) Value() (driver.Value, error) {
type CloudIntegration struct {
bun.BaseModel `bun:"table:cloud_integration"`
Identifiable
TimeAuditable
types.Identifiable
types.TimeAuditable
Provider string `json:"provider" bun:"provider,type:text,unique:provider_id"`
Config *AccountConfig `json:"config" bun:"config,type:text"`
AccountID *string `json:"account_id" bun:"account_id,type:text"`
@@ -194,8 +195,8 @@ func (r *AgentReport) Value() (driver.Value, error) {
type CloudIntegrationService struct {
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
Identifiable
TimeAuditable
types.Identifiable
types.TimeAuditable
Type string `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
Config CloudServiceConfig `bun:"config,type:text"`
CloudIntegrationID string `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integrations(id),on_delete:cascade"`

View File

@@ -301,6 +301,7 @@ type ListMetricsParams struct {
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
Source string `query:"source"`
}
// Validate ensures ListMetricsParams contains acceptable values.

View File

@@ -1,17 +1,18 @@
package types
package savedviewtypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/types"
"github.com/uptrace/bun"
)
type SavedView struct {
bun.BaseModel `bun:"table:saved_views"`
Identifiable
TimeAuditable
UserAuditable
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"`