Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi Kumar
053d306e99 chore: pr review fixes 2026-07-05 01:58:57 +05:30
Abhi Kumar
6693bb7a06 chore: removed duplicate tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
8f4c00849c chore: fixed lint issue 2026-07-05 01:58:57 +05:30
Abhi Kumar
77390d5a5e chore: cleaned up duplicate tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
cc94b87357 chore: ran oxfmt 2026-07-05 01:58:57 +05:30
Abhi Kumar
adb60c4683 chore: added more tests 2026-07-05 01:58:57 +05:30
Abhi Kumar
7d8f896a5f test: added e2e tests for dashboard create flow 2026-07-05 01:58:56 +05:30
112 changed files with 1729 additions and 3694 deletions

View File

@@ -56,8 +56,6 @@ jobs:
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
- clickhousecluster
- metricreduction
sqlstore-provider:
- postgres
- sqlite

View File

@@ -1,7 +1,7 @@
import { QueryParams } from 'constants/query';
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};

View File

@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -52,7 +52,7 @@ const ADD_ONS = [
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
},
{
icon: <ScrollText size={14} />,
@@ -61,7 +61,7 @@ const ADD_ONS = [
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
},
{
icon: <ScrollText size={14} />,
@@ -70,7 +70,7 @@ const ADD_ONS = [
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
@@ -79,7 +79,7 @@ const ADD_ONS = [
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
@@ -88,7 +88,7 @@ const ADD_ONS = [
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
},
];
@@ -99,7 +99,7 @@ const REDUCE_TO = {
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
};
const hasValue = (value: unknown): boolean =>
@@ -349,7 +349,7 @@ function QueryAddOns({
<TooltipContent
label="Group By"
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
/>
}
placement="top"
@@ -385,7 +385,7 @@ function QueryAddOns({
<TooltipContent
label="Having"
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
/>
}
placement="top"
@@ -434,7 +434,7 @@ function QueryAddOns({
<TooltipContent
label="Order By"
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
/>
}
placement="top"
@@ -473,7 +473,7 @@ function QueryAddOns({
<TooltipContent
label="Reduce to"
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
/>
}
placement="top"

View File

@@ -65,7 +65,7 @@ function QueryAggregationOptions({
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -676,7 +676,7 @@ function QueryAggregationSelect({
</span>
<br />
<a
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}

View File

@@ -44,7 +44,7 @@ function TraceOperatorSection({
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
target="_blank"
style={{ textDecoration: 'underline' }}
>
@@ -106,7 +106,7 @@ export default function QueryFooter({
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>

View File

@@ -1,5 +1,5 @@
export const apDexToolTipText =
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
export const apDexToolTipUrl =
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
export const apDexToolTipUrlText = 'Learn more about Apdex.';

View File

@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/setup-alerts-notification/"
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
/>
<Tooltip

View File

@@ -29,7 +29,8 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
let url = '';
switch (option) {
case AlertTypes.ANOMALY_BASED_ALERT:
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
url =
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
break;
case AlertTypes.METRICS_BASED_ALERT:
url =

View File

@@ -31,7 +31,8 @@ export const ALERT_TYPE_URL_MAP: Record<
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},
[AlertTypes.ANOMALY_BASED_ALERT]: {
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
selection:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
creation:
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
},

View File

@@ -717,13 +717,13 @@ function ExplorerOptions({
const infoIconLink = useMemo(() => {
if (isLogsExplorer) {
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
}
// TODO: Add metrics explorer info icon link
if (isMetricsExplorer) {
return '';
}
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
}, [isLogsExplorer, isMetricsExplorer]);
const getQueryName = (query: Query): string => {

View File

@@ -201,7 +201,7 @@ export default function SavedViews({
});
window.open(
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
'https://signoz.io/docs/product-features/saved-view/',
'_blank',
'noopener noreferrer',
);

View File

@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
export const DOCS_LINKS = {
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
SETUP_SAVED_VIEWS:
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
};

View File

@@ -82,7 +82,7 @@ export function K8sEmptyState({
<span className={styles.message}>
Please refer to{' '}
<a
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
href="https://signoz.io/docs/userguide/hostmetrics/"
target="_blank"
rel="noreferrer"
>

View File

@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
'https://signoz.io/docs/userguide/hostmetrics/',
);
});
});

View File

@@ -1,228 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesContentProps {
onCreateNewDashboard: () => void;
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
onCancel?: () => void;
}
// The template gallery (search + list + preview + create), extracted from the
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
export default function DashboardTemplatesContent({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesContentProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<div className="new-dashboard-templates-content-container">
{onCancel && (
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
)}
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,129 @@
import { Modal } from 'antd';
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import DashboardTemplatesContent from './DashboardTemplatesContent';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
@@ -15,6 +135,20 @@ export default function DashboardTemplatesModal({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesModalProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<Modal
wrapClassName="new-dashboard-templates-modal"
@@ -25,10 +159,75 @@ export default function DashboardTemplatesModal({
destroyOnClose
width="60vw"
>
<DashboardTemplatesContent
onCreateNewDashboard={onCreateNewDashboard}
onCancel={onCancel}
/>
<div className="new-dashboard-templates-content-container">
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
Meter metrics data is aggregated over 1 hour period. Please select time
range accordingly.&nbsp;
<a
href="https://signoz.io/docs/cost-meter/overview/#get-started"
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
rel="noopener noreferrer"
target="_blank"
style={{ textDecoration: 'underline' }}

View File

@@ -197,7 +197,7 @@ function TopOperationsTable({
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
url: 'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
urlText: 'Learn more about Entrypoint Spans.',
};

View File

@@ -64,7 +64,7 @@ function ConfigureGoogleAuthAuthnProvider({
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>

View File

@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>

View File

@@ -37,7 +37,7 @@ function ConfigureSAMLAuthnProvider({
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>

View File

@@ -216,7 +216,7 @@ export default function QueryFunctions({
Add new function
<Typography.Link
style={{ textDecoration: 'underline' }}
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
target="_blank"
>
{' '}

View File

@@ -100,7 +100,7 @@ function Version(): JSX.Element {
{!isError && !isLatestVersion && (
<div className="version-page-upgrade-container">
<Button
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
target="_blank"
type="primary"
className="periscope-btn primary"

View File

@@ -32,34 +32,6 @@
cursor: help;
}
.publicLink {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton:disabled {
cursor: default;
}
.divider {
flex-shrink: 0;
width: 1px;

View File

@@ -1,12 +1,5 @@
import { KeyboardEvent } from 'react';
import {
Check,
Globe,
LockKeyhole,
LockKeyholeOpen,
SolidInfoCircle,
X,
} from '@signozhq/icons';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -14,7 +7,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
@@ -26,13 +18,7 @@ interface DashboardInfoProps {
tags: string[];
description: string;
isPublicDashboard: boolean;
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
publicUrl: string;
isDashboardLocked: boolean;
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
showLockToggle: boolean;
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
onToggleLock?: () => void;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
@@ -47,10 +33,7 @@ function DashboardInfo({
tags,
description,
isPublicDashboard,
publicUrl,
isDashboardLocked,
showLockToggle,
onToggleLock,
isEditing,
draft,
onDraftChange,
@@ -68,17 +51,6 @@ function DashboardInfo({
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -129,7 +101,7 @@ function DashboardInfo({
</Button>
</div>
) : (
<TooltipSimple title={title} disableHoverableContent>
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
@@ -143,7 +115,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple title={description} disableHoverableContent>
<TooltipSimple title={description}>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
@@ -153,44 +125,14 @@ function DashboardInfo({
)}
{isPublicDashboard && (
<TooltipSimple
title="This dashboard is publicly accessible. Click to open the public page."
disableHoverableContent
>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.publicLink}
aria-label="Open public dashboard"
testId="dashboard-public-link"
onClick={(): void => openInNewTab(publicUrl)}
>
<Globe size={14} />
</Button>
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{showLockToggle && (
<TooltipSimple title={lockTooltip} disableHoverableContent>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.lockButton}
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
testId="dashboard-lock"
disabled={!onToggleLock}
onClick={onToggleLock}
>
{isDashboardLocked ? (
<LockKeyhole size={14} />
) : (
<LockKeyholeOpen size={14} />
)}
</Button>
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
@@ -203,14 +145,14 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="sienna" variant="outline">
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="sienna"
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -15,12 +15,9 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -39,15 +36,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
useEffect(() => {
setIsDashboardLocked(!!dashboard.locked);
setShowLockToggle(!!dashboard.locked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.id]);
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -69,36 +58,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) &&
dashboard.createdBy !== 'integration';
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
refetch();
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
@@ -146,11 +119,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}

View File

@@ -2,7 +2,6 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -8,7 +8,7 @@ import {
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
import type {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -77,7 +77,7 @@ function ThresholdsSection({
yAxisUnit,
tableColumns = [],
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const variant = controls?.variant ?? 'label';
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);

View File

@@ -4,10 +4,7 @@ import {
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ThresholdVariant,
type AnyThreshold,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
@@ -24,7 +21,7 @@ function ComparisonThresholdsSection(props: {
value={props.value}
onChange={props.onChange as (next: AnyThreshold[]) => void}
yAxisUnit={props.yAxisUnit}
controls={{ variant: ThresholdVariant.COMPARISON }}
controls={{ variant: 'comparison' }}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
@@ -61,42 +61,24 @@ function Header({
</Button>
</div>
<DialogWrapper
<ConfirmDialog
open={discard.open}
onOpenChange={(next: boolean): void => {
onOpenChange={(next): void => {
if (!next) {
discard.cancel();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
testId="panel-editor-v2-discard-modal"
footer={
<>
<Button
type="button"
variant="solid"
color="destructive"
data-testid="panel-editor-v2-discard-confirm"
loading={discard.isPending}
onClick={discard.confirm}
>
Discard
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
data-testid="panel-editor-v2-discard-cancel"
onClick={discard.cancel}
>
Keep editing
</Button>
</>
}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={discard.confirm}
onCancel={discard.cancel}
data-testid="panel-editor-v2-discard-modal"
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</DialogWrapper>
</ConfirmDialog>
</div>
);
}

View File

@@ -23,8 +23,6 @@ interface PreviewPaneProps {
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
/** Showing a prior page's data while the next loads; forwarded so the list shows skeleton rows. */
isPreviousData?: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
@@ -45,7 +43,6 @@ function PreviewPane({
panelDefinition,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
@@ -90,7 +87,6 @@ function PreviewPane({
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -1,5 +1,7 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -26,21 +28,20 @@ function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
} as unknown as DashboardtypesPanelSpecDTO;
}
// Thin wrapper — only prove delegation; seeding rules are covered in buildPluginSpec.test.ts.
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it("resolves the target kind's sections and carries the old spec through them", () => {
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [
{ kind: 'legend', controls: { position: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
],
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
});
const old = specWith({ formatting: { unit: 'ms', decimalPrecision: 2 } });
const result = getSwitchedPluginSpec(
old,
@@ -48,12 +49,25 @@ describe('getSwitchedPluginSpec', () => {
TelemetrytypesSignalDTO.logs,
);
expect(mockGetPanelDefinition).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
expect(result.legend?.position).toBe('bottom');
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('forwards the signal to seed List columns', () => {
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
@@ -69,4 +83,155 @@ describe('getSwitchedPluginSpec', () => {
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/PieChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,27 +1,149 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
buildPluginSpec,
type SeededPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildPluginSpec';
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
export type SwitchedPluginSpec = SeededPluginSpec;
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
/**
* Plugin spec for a first-visit switch to `newKind`: the kind's defaults plus the cross-kind
* config each section carries from `oldSpec`. Revisiting a kind restores its stash instead.
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
return buildPluginSpec(getPanelDefinition(newKind).sections, {
oldSpec,
signal,
});
const sections = getPanelDefinition(newKind).sections;
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === SectionKind.Formatting)) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === SectionKind.Columns)) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
}

View File

@@ -49,7 +49,6 @@ const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
@@ -132,39 +131,7 @@ describe('usePanelTypeSwitch', () => {
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }, { orderBy: undefined }] },
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
persisted.builder.queryData.forEach((qd) => {
expect(qd.orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {

View File

@@ -11,10 +11,7 @@ import {
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type {
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
@@ -28,25 +25,6 @@ import {
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
@@ -138,21 +116,16 @@ export function usePanelTypeSwitch({
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(nextQuery, newPanelType),
toPerses(transformed, newPanelType),
),
);
redirectWithQueryBuilderData(nextQuery);
redirectWithQueryBuilderData(transformed);
},
[setSpec, redirectWithQueryBuilderData],
);

View File

@@ -102,19 +102,12 @@ function PanelEditorContainer({
// One shared query result for the whole editor; the preview renders it.
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
const { data, isFetching, error, cancelQuery, refetch, pagination } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
@@ -242,7 +235,6 @@ function PanelEditorContainer({
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -1,8 +1,4 @@
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
@@ -14,9 +10,6 @@ export const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef } from 'react';
import { Select, Skeleton, Table } from 'antd';
import { Select, Table } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
@@ -36,7 +36,6 @@ function ListPanelRenderer({
refetch,
searchTerm = '',
pagination,
isPreviousData = false,
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
// Pin the header while the body scrolls (shared with the Table kind).
const containerRef = useRef<HTMLDivElement>(null);
@@ -116,25 +115,6 @@ function ListPanelRenderer({
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
const showPager = !!pagination;
// While the next page loads, swap the stale rows (held by keepPreviousData) for skeleton bars,
// keeping the header + pager. Row count mirrors the page being left.
const skeletonRowCount = dataSource.length || pagination?.pageSize || 10;
const skeletonColumns = useMemo(
() =>
resizableColumns.map((col) => ({
...col,
render: (): JSX.Element => <Skeleton.Input active block size="small" />,
})),
[resizableColumns],
);
const skeletonRows = useMemo(
() =>
Array.from({ length: skeletonRowCount }, (_, index) => ({
key: `skeleton-${index}`,
})) as unknown as typeof filteredDataSource,
[skeletonRowCount],
);
return (
<div
ref={containerRef}
@@ -154,13 +134,13 @@ function ListPanelRenderer({
<Table
size="small"
tableLayout="fixed"
columns={isPreviousData ? skeletonColumns : resizableColumns}
columns={resizableColumns}
components={components}
dataSource={isPreviousData ? skeletonRows : filteredDataSource}
dataSource={filteredDataSource}
pagination={false}
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
scroll={{ y: scrollY }}
onRow={isPreviousData ? undefined : onRow}
onRow={onRow}
/>
</div>
{showPager && pagination && (

View File

@@ -162,18 +162,4 @@ describe('ListPanelRenderer', () => {
expect(queryByTestId('list-panel-pager')).not.toBeInTheDocument();
});
it('swaps rows for skeletons while the next page loads (isPreviousData), keeping header + pager', () => {
const { getByText, queryByText, getByTestId, container } = renderPanel({
data: dataWith([{ data: { body: 'stale row' } }]),
pagination: makePagination({ canNext: true }),
isPreviousData: true,
});
expect(getByText('body')).toBeInTheDocument();
expect(getByTestId('list-panel-pager')).toBeInTheDocument();
// Stale page content is replaced by skeleton bars.
expect(queryByText('stale row')).not.toBeInTheDocument();
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});

View File

@@ -1,8 +1,4 @@
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
@@ -10,9 +6,6 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.COMPARISON },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,8 +1,4 @@
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
// A table panel renders one scalar result (the V5 backend joins every query into a
// single column set). It exposes the per-panel time scope, formatting (decimals +
@@ -16,9 +12,6 @@ export const sections: SectionConfig[] = [
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.TABLE },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'table' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,8 +1,4 @@
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
@@ -22,9 +18,6 @@ export const sections: SectionConfig[] = [
spanGaps: true,
},
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,76 +0,0 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { buildTimeSeriesConfig } from '../buildConfig';
const series: PanelSeries[] = [
{
queryName: 'A',
legend: 'A',
labels: {},
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 2 },
],
kind: 'series',
aggregation: { index: 0, alias: 'A' },
},
];
/** Resolved per-series `spanGaps` values from a built TimeSeries config. */
function spanGapsFor(
spanGaps: unknown,
stepIntervals?: Record<string, number>,
): (boolean | number | undefined)[] {
const spec = {
chartAppearance: spanGaps ? { spanGaps } : {},
} as unknown as DashboardtypesTimeSeriesPanelSpecDTO;
return buildTimeSeriesConfig({
panelId: 'p1',
spec,
builderQueries: [],
series,
stepIntervals,
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
})
.getSeriesSpanGapsOptions()
.map((o) => o.spanGaps);
}
describe('buildTimeSeriesConfig spanGaps', () => {
it('floors a numeric threshold below the step interval at the step interval', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60 }),
).toStrictEqual([60]);
});
it('keeps a numeric threshold larger than the step interval', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '300s' }, { A: 60 }),
).toStrictEqual([300]);
});
it('uses the smallest step interval across queries as the floor', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }, { A: 60, B: 30 }),
).toStrictEqual([30]);
});
it('leaves boolean span-all (true) untouched', () => {
expect(spanGapsFor(undefined, { A: 60 })).toStrictEqual([true]);
// fillOnlyBelow false → resolveSpanGaps returns true → not clamped.
expect(
spanGapsFor({ fillOnlyBelow: false, fillLessThan: '10s' }, { A: 60 }),
).toStrictEqual([true]);
});
it('passes a numeric threshold through when no step intervals are known', () => {
expect(
spanGapsFor({ fillOnlyBelow: true, fillLessThan: '10s' }),
).toStrictEqual([10]);
});
});

View File

@@ -2,10 +2,7 @@ import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/service
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import {
buildBaseConfig,
minStepInterval,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
@@ -83,14 +80,7 @@ export function buildTimeSeriesConfig({
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
@@ -100,8 +90,6 @@ interface AddSeriesArgs {
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
/** Per-query step intervals (seconds); floor for a numeric spanGaps threshold. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
@@ -114,23 +102,15 @@ function addSeries({
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const resolvedSpanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance.spanGaps)
const spanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
: true;
// A numeric spanGaps is a max-gap threshold (seconds); floor it at the step interval so a
// sub-step value doesn't break the line at every normal point. Boolean `true` passes through.
const minStep = stepIntervals ? minStepInterval(stepIntervals) : undefined;
const spanGaps =
typeof resolvedSpanGaps === 'number' && minStep !== undefined
? Math.max(minStep, resolvedSpanGaps)
: resolvedSpanGaps;
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -34,8 +34,6 @@ export interface BaseRendererProps {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
isFetching: boolean;
/** Showing a prior page's data while the next loads; list renderers swap in skeleton rows. */
isPreviousData?: boolean;
error: Error | null;
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
refetch?: () => void;

View File

@@ -58,11 +58,7 @@ export enum SectionKind {
* - `comparison` — value crosses an operator → recolor (Number)
* - `table` — per-column comparison (Table)
*/
export enum ThresholdVariant {
LABEL = 'label',
COMPARISON = 'comparison',
TABLE = 'table',
}
export type ThresholdVariant = 'label' | 'comparison' | 'table';
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
export type AnyThreshold =

View File

@@ -0,0 +1,67 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
describe('buildDefaultPluginSpec', () => {
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds only the legend for Histogram (no visualization section)', () => {
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('returns an empty spec for a kind with no seeded controls (List)', () => {
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
});
it('does not seed controls that already show a clear default', () => {
// `axes` and `formatting` stay unset — their empty state is the chart default.
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
it('only seeds the legend position when the kind exposes that control', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
});

View File

@@ -13,14 +13,6 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a List panel without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
const spec = queries[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBeUndefined();
});
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);

View File

@@ -1,328 +0,0 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
import { buildPluginSpec } from '../buildPluginSpec';
jest.mock('../../../PanelEditor/ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
describe('buildPluginSpec', () => {
describe('folding mechanism', () => {
it('returns an empty spec for no sections', () => {
expect(buildPluginSpec([])).toStrictEqual({});
});
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
{ kind: SectionKind.ContextLinks },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
const result = buildPluginSpec([
{ kind: SectionKind.Legend, controls: { colors: true } },
]);
expect(result).toStrictEqual({});
expect(result).not.toHaveProperty('legend');
});
it('composes defaults and carried config from several sections in one pass', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 2 },
});
expect(buildPluginSpec(sections, { oldSpec })).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
formatting: { unit: 'ms', decimalPrecision: 2 },
});
});
});
describe('visualization / legend seeds', () => {
it('seeds visualization global_time and legend bottom when those controls are on', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Legend, controls: { position: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds neither when their defaulting controls are absent', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
describe('chartAppearance seed', () => {
it('seeds only the declared defaulting controls', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { lineStyle: true, fillMode: true },
},
];
expect(buildPluginSpec(sections).chartAppearance).toStrictEqual({
lineStyle: DashboardtypesLineStyleDTO.solid,
fillMode: DashboardtypesFillModeDTO.none,
});
});
it('seeds nothing when only non-defaulting controls are declared (showPoints/spanGaps)', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { showPoints: true, spanGaps: true },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
describe('formatting seed (carry, gated by controls)', () => {
it('carries unit + decimalPrecision when the kind declares both', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 3 },
});
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
unit: 'ms',
decimalPrecision: 3,
});
});
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
const oldSpec = oldSpecWith({
formatting: { unit: 'ms', decimalPrecision: 2 },
});
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 2,
});
});
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith({ formatting: { decimalPrecision: 0 } });
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 0,
});
});
it('seeds no formatting on a new panel or when nothing supported is present', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
expect(
buildPluginSpec(sections, {
oldSpec: oldSpecWith({ formatting: { unit: 'ms' } }),
}),
).toStrictEqual({});
});
});
describe('columns seed', () => {
it('seeds the signal default columns when a Columns section is present', () => {
const columns = [{ name: 'timestamp' }, { name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
const result = buildPluginSpec([{ kind: SectionKind.Columns }], {
signal: TelemetrytypesSignalDTO.traces,
});
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.traces,
);
expect(result.selectFields).toBe(columns);
});
it('seeds nothing (and skips the lookup) when no signal is in context', () => {
const result = buildPluginSpec([{ kind: SectionKind.Columns }]);
expect(mockDefaultColumnsForSignal).not.toHaveBeenCalled();
expect(result).toStrictEqual({});
});
});
describe('thresholds seed (variant remap)', () => {
function switchThresholds(
variant: ThresholdVariant | undefined,
thresholds: unknown[],
): unknown {
const sections: SectionConfig[] = [
{ kind: SectionKind.Thresholds, controls: { variant } },
];
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
.thresholds;
}
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
expect(
switchThresholds(undefined, [
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]),
).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label → comparison, seeding operator + format and dropping label', () => {
expect(
switchThresholds(ThresholdVariant.COMPARISON, [
{ value: 80, color: '#F1575F', label: 'warn' },
]),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('preserves existing operator/format when remapping comparison → table', () => {
expect(
switchThresholds(ThresholdVariant.TABLE, [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
]),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops table-only operator/format/columnName when remapping table → label', () => {
expect(
switchThresholds(ThresholdVariant.LABEL, [
{
value: 0,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
]),
).toStrictEqual([{ value: 0, color: '#F1575F' }]);
});
it('seeds nothing for an empty or absent threshold list', () => {
expect(switchThresholds(ThresholdVariant.LABEL, [])).toBeUndefined();
const sections: SectionConfig[] = [
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
});
// Integration against real kind configs — guards against a section-config regression.
describe('per-kind defaults (real sections, no context)', () => {
it('seeds the full TimeSeries default set', () => {
expect(buildPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('returns an empty spec for List (only switchPanelKind, nothing to seed)', () => {
expect(buildPluginSpec(listSections)).toStrictEqual({});
});
});
});

View File

@@ -178,11 +178,11 @@ export function mapThresholds(
}
/**
* Smallest per-query step interval (seconds), fed to uPlot so tick density matches the
* Smallest per-query step interval, fed to uPlot so tick density matches the
* densest query. Falls back to `undefined` (uPlot "auto") on an empty map, since
* `Math.min` returns `Infinity` there and would corrupt scale math.
*/
export function minStepInterval(
function minStepInterval(
stepIntervals: Record<string, number>,
): number | undefined {
const values = Object.values(stepIntervals);

View File

@@ -0,0 +1,73 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
type SectionConfig,
type SectionSpecMap,
} from '../types/sections';
/**
* Seeded plugin-spec slices, typed as canonical section slices so each value is
* checked against its DTO. A partial cross-section, not any single kind's spec,
* so the union cast stays localized to `createDefaultPanel`.
*/
export interface DefaultPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
}
/**
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
* config pane opens populated. Values equal the renderer fallbacks (display only).
* Controls whose empty state already IS the default are left unset.
*/
export function buildDefaultPluginSpec(
sections: SectionConfig[],
): DefaultPluginSpec {
const spec: DefaultPluginSpec = {};
sections.forEach((section) => {
switch (section.kind) {
case SectionKind.Visualization:
if (section.controls.timePreference) {
spec.visualization = {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
};
}
break;
case SectionKind.Legend:
if (section.controls.position) {
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
}
break;
case SectionKind.ChartAppearance: {
const chartAppearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (section.controls.lineStyle) {
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (section.controls.lineInterpolation) {
chartAppearance.lineInterpolation =
DashboardtypesLineInterpolationDTO.spline;
}
if (section.controls.fillMode) {
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
}
if (Object.keys(chartAppearance).length > 0) {
spec.chartAppearance = chartAppearance;
}
break;
}
default:
break;
}
});
return spec;
}

View File

@@ -1,201 +0,0 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTimePreferenceDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
type SectionControls,
SectionKind,
type SectionSpecMap,
ThresholdVariant,
} from '../types/sections';
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
export interface SeededPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: SectionSpecMap[SectionKind.Columns];
thresholds?: AnyThreshold[];
}
export interface SeedContext {
/** Present only on a kind switch — the spec being switched away from, to carry config across. */
oldSpec?: DashboardtypesPanelSpecDTO;
signal?: TelemetrytypesSignalDTO;
}
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** Remaps a threshold to the target variant, seeding the fields that variant needs to stay functional. */
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === ThresholdVariant.COMPARISON) {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === ThresholdVariant.TABLE) {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* How one section derives its plugin-spec slice on create/switch — the single place a section
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
*/
interface SectionSeed {
specKey: keyof SeededPluginSpec;
seed: (controls: unknown, ctx: SeedContext) => unknown;
}
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
const c = controls as SectionControls[SectionKind.Visualization];
return c.timePreference
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
: undefined;
},
},
[SectionKind.Legend]: {
specKey: 'legend',
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
const c = controls as SectionControls[SectionKind.Legend];
return c.position
? { position: DashboardtypesLegendPositionDTO.bottom }
: undefined;
},
},
[SectionKind.ChartAppearance]: {
specKey: 'chartAppearance',
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
const c = controls as SectionControls[SectionKind.ChartAppearance];
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (c.lineStyle) {
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (c.lineInterpolation) {
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
}
if (c.fillMode) {
appearance.fillMode = DashboardtypesFillModeDTO.none;
}
return Object.keys(appearance).length > 0 ? appearance : undefined;
},
},
[SectionKind.Formatting]: {
specKey: 'formatting',
seed: (
controls,
{ oldSpec },
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
const c = controls as SectionControls[SectionKind.Formatting];
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
?.formatting;
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
// else the save API rejects the spec.
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
...(c.decimals &&
old?.decimalPrecision !== undefined && {
decimalPrecision: old.decimalPrecision,
}),
};
return Object.keys(carried).length > 0 ? carried : undefined;
},
},
[SectionKind.Columns]: {
specKey: 'selectFields',
seed: (
_controls,
{ signal },
): SectionSpecMap[SectionKind.Columns] | undefined => {
if (!signal) {
return undefined;
}
const columns = defaultColumnsForSignal(signal);
return columns.length > 0 ? columns : undefined;
},
},
[SectionKind.Thresholds]: {
specKey: 'thresholds',
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
const c = controls as SectionControls[SectionKind.Thresholds];
const variant = c.variant ?? ThresholdVariant.LABEL;
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
?.thresholds;
if (!old || old.length === 0) {
return undefined;
}
return old.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, variant),
);
},
},
};
/**
* Builds a kind's plugin spec from its declared `sections`: no context → per-kind defaults
* (new panel); `{ oldSpec, signal }` → defaults plus the config each target section carries.
*/
export function buildPluginSpec(
sections: SectionConfig[],
ctx: SeedContext = {},
): SeededPluginSpec {
const spec: SeededPluginSpec = {};
sections.forEach((section) => {
const entry = SECTION_SEEDS[section.kind];
if (!entry) {
return;
}
const controls = 'controls' in section ? section.controls : undefined;
const value = entry.seed(controls, ctx);
if (value !== undefined) {
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
(spec as Record<string, unknown>)[entry.specKey] = value;
}
});
return spec;
}

View File

@@ -59,13 +59,12 @@ function Panel({
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { data, isFetching, error, refetch, pagination } = usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -93,7 +92,6 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -6,7 +6,6 @@ import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/compon
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { getResponseType } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import type {
PanelPagination,
PanelQueryData,
@@ -22,8 +21,6 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
@@ -47,7 +44,6 @@ function PanelBody({
panelId,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
@@ -56,11 +52,9 @@ function PanelBody({
searchTerm,
pagination,
}: PanelBodyProps): JSX.Element {
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
const hasData =
!!data.response &&
getResponseType(data.response) === data.requestPayload?.requestType;
// react-query keeps the previous response during refetches, so its presence is
// the "have something to show" signal — only fail hard when there's nothing.
const hasData = !!data.response;
const queries = panel.spec.queries;
// Not-configured panel: no runnable query, so nothing to error/load on.
@@ -95,9 +89,7 @@ function PanelBody({
);
}
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
if (isFetching && !hasData) {
if (isFetching) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
@@ -112,7 +104,6 @@ function PanelBody({
panel={panel}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -42,8 +42,8 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
const runnablePanel = (): DashboardtypesPanelDTO =>
panelWith([
it('renders the kind renderer once a runnable query is present', () => {
const panel = panelWith([
{
spec: {
plugin: {
@@ -58,62 +58,9 @@ describe('PanelBody', () => {
},
]);
it('renders the kind renderer once a runnable query is present', () => {
render(<PanelBody {...baseProps} panel={runnablePanel()} />);
render(<PanelBody {...baseProps} panel={panel} />);
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-query')).not.toBeInTheDocument();
});
it('shows the full-panel loader only on the first fetch (no data yet)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={
{
response: { data: { type: 'raw' } },
requestPayload: { requestType: 'raw' },
} as unknown as PanelQueryData
}
isFetching
/>,
);
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('panel-loading')).not.toBeInTheDocument();
});
it('shows the loader (not a NoData flash) while a stale cross-type response is replaced on a kind switch', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={
{
response: { data: { type: 'time_series' } },
requestPayload: { requestType: 'raw' },
} as unknown as PanelQueryData
}
isFetching
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
});

View File

@@ -21,7 +21,6 @@ import {
buildCreateAlertUrl,
readPanelUnit,
} from '../utils/buildCreateAlertUrl';
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
/**
* Callback that seeds the alert builder from a panel's query in a new tab (V1 parity
@@ -62,8 +61,8 @@ export function useCreateAlertFromPanel(): (
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
startMs: minTime / 1e6,
endMs: maxTime / 1e6,
variables,
});

View File

@@ -328,12 +328,6 @@ describe('usePanelQuery', () => {
expect(result.current.pagination).toBeUndefined();
});
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(keepPreviousData).toBe(true);
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
@@ -383,20 +377,26 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.canNext).toBe(false);
});
it('drives canNext from the response cursor, not the row count', () => {
// Full page but no cursor → backend says these are the last rows.
it('flags canNext on a full page and clears it on a partial page', () => {
withResponse(rawResponse(25));
const noCursor = renderHook(() =>
const full = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(noCursor.result.current.pagination?.canNext).toBe(false);
expect(full.result.current.pagination?.canNext).toBe(true);
// Cursor present (even on a partial page) → more rows.
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
withResponse(rawResponse(10));
const partial = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
expect(partial.result.current.pagination?.canNext).toBe(false);
});
it('flags canNext from a nextCursor even on a partial page', () => {
withResponse(rawResponse(3, 'cursor-1'));
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.canNext).toBe(true);
});
it('advances pageIndex and enables canPrev after goNext', () => {

View File

@@ -11,8 +11,6 @@ export interface UseGetQueryRangeV5Args {
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
queryKey: unknown[];
enabled: boolean;
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
keepPreviousData?: boolean;
}
/**
@@ -42,13 +40,11 @@ export function useGetQueryRangeV5({
requestPayload,
queryKey,
enabled,
keepPreviousData,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
enabled,
retry: retryUnlessClientError,
keepPreviousData,
});
}

View File

@@ -55,8 +55,6 @@ export interface UsePanelQueryResult {
isLoading: boolean;
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
@@ -213,8 +211,6 @@ export function usePanelQuery({
requestPayload,
queryKey,
enabled: enabled && runnable,
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
keepPreviousData: isPaginated,
});
const queryClient = useQueryClient();
@@ -236,8 +232,8 @@ export function usePanelQuery({
[pageSize],
);
// Paging handles for raw/list panels. The backend sets `nextCursor` iff the page filled,
// so it's the authoritative has-more signal (there's no total count on the wire).
// Paging handles for raw/list panels. `canNext` is a heuristic (no total count on the wire):
// a full page or a response `nextCursor` implies more rows.
const pagination = useMemo<PanelPagination | undefined>(() => {
if (!isPaginated) {
return undefined;
@@ -250,10 +246,11 @@ export function usePanelQuery({
// `getRawResults` returns [] for a missing/non-raw response, so this stays
// defined and zero-rowed rather than throwing while data is absent.
const result = getRawResults(response.data)[0];
const rowCount = result?.rows?.length ?? 0;
return {
pageIndex: Math.floor(safeOffset / safePageSize),
canPrev: safeOffset > 0,
canNext: !!result?.nextCursor,
canNext: !!result?.nextCursor || rowCount >= safePageSize,
goPrev,
goNext,
pageSize: safePageSize,
@@ -274,7 +271,6 @@ export function usePanelQuery({
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,
cancelQuery,

View File

@@ -37,24 +37,6 @@ describe('applyJsonPatch', () => {
expect(JSON.stringify(doc)).toBe(snapshot);
});
it('does not mutate the input ops when a later op targets a just-added node', () => {
// New-panel-on-empty-dashboard batch: add an empty section, then add an
// item into it. The item must not leak back into the section-add op's value
// (which is still queued for the network request) via a shared reference.
const ops = [
op(add, '/spec/layouts/-', { spec: { items: [] } }),
op(add, '/spec/layouts/0/spec/items/-', { x: 0, y: 0 }),
];
const empty = { spec: { layouts: [] } };
const next = applyJsonPatch(empty, ops);
// The section-add op's value stays empty — only the applied document grows.
expect((ops[0].value as any).spec.items).toStrictEqual([]);
expect((next.spec as any).layouts[0].spec.items).toStrictEqual([
{ x: 0, y: 0 },
]);
});
it('replaces a leaf string', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),

View File

@@ -136,14 +136,10 @@ function applyOperation(
const key = tokens[tokens.length - 1];
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
// Clone the inserted value: a later op in the same batch can target a node we
// just added (e.g. add an empty section, then add an item into it), and writing
// the value by reference would mutate the caller's `op.value` — corrupting the
// ops still queued for the network request.
if (op.op === DashboardtypesPatchOpDTO.add) {
addAt(parent, key, cloneDeep(op.value));
addAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
replaceAt(parent, key, cloneDeep(op.value));
replaceAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
removeAt(parent, key);
}

View File

@@ -12,7 +12,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from './Panels/types/panelKind';
import type { SeededPluginSpec } from './Panels/utils/buildPluginSpec';
import type { DefaultPluginSpec } from './Panels/utils/buildDefaultPluginSpec';
import type { GridItem } from './utils';
/**
@@ -36,7 +36,7 @@ export function panelRef(panelId: string): string {
*/
export function createDefaultPanel(
pluginKind: PanelKind,
pluginSpec: SeededPluginSpec = {},
pluginSpec: DefaultPluginSpec = {},
queries: DashboardtypesQueryDTO[] = [],
): DashboardtypesPanelDTO {
return {

View File

@@ -188,80 +188,9 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
offset: 100,
limit: 50,
order: [
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
],
});
});
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: { key?: { name?: string }; direction?: string }[];
};
expect(spec.order).toStrictEqual([
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
]);
});
it('appends an id tiebreaker to a logs list order (mirroring the primary direction)', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({
name: 'A',
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: { key?: { name?: string }; direction?: string }[];
};
expect(spec.order).toStrictEqual([
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
]);
});
it('does not duplicate an id tiebreaker already present in the order', () => {
const order = [
{ key: { name: 'timestamp' }, direction: 'asc' },
{ key: { name: 'id' }, direction: 'asc' },
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: unknown[];
};
expect(spec.order).toStrictEqual(order);
});
it('does not add an id tiebreaker for a traces list order (explorer parity)', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = request.compositeQuery?.queries?.[0]?.spec as {
order?: unknown[];
};
expect(spec.order).toStrictEqual(order);
});
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),

View File

@@ -111,45 +111,6 @@ describe('persesQueryAdapters', () => {
expect(result[0].kind).toBe('raw');
expect(result[0].spec.plugin.kind).toBe('signoz/BuilderQuery');
});
it('drops the pageSize-promoted limit for a List query with no user limit (so it pages server-side)', () => {
// pageSize with no user limit would otherwise be folded into the V5 limit.
const withPageSize: Query = {
...initialQueriesMap[DataSource.LOGS],
builder: {
...initialQueriesMap[DataSource.LOGS].builder,
queryData: [
{
...initialQueriesMap[DataSource.LOGS].builder.queryData[0],
limit: null,
pageSize: 100,
},
],
},
};
const result = toPerses(withPageSize, PANEL_TYPES.LIST);
const spec = result[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBeUndefined();
});
it('keeps an explicit user limit on a List query (V1 parity: static, unpaged cap)', () => {
const withLimit: Query = {
...initialQueriesMap[DataSource.LOGS],
builder: {
...initialQueriesMap[DataSource.LOGS].builder,
queryData: [
{ ...initialQueriesMap[DataSource.LOGS].builder.queryData[0], limit: 50 },
],
},
};
const result = toPerses(withLimit, PANEL_TYPES.LIST);
const spec = result[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBe(50);
});
});
describe('round-trip', () => {

View File

@@ -3,14 +3,12 @@ import type {
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5ClickHouseQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
@@ -26,7 +24,6 @@ interface QuerySpecView {
signal?: string;
stepInterval?: number | string;
aggregations?: { metricName?: string }[];
order?: Querybuildertypesv5OrderByDTO[];
}
/**
@@ -169,48 +166,6 @@ function withBarStepInterval(
});
}
/**
* Enforces a total order on logs-list requests so offset paging can't duplicate/drop
* same-millisecond rows: default the primary to `timestamp desc`, then always append `id`
* (logs-explorer parity). Request-only; traces keep their order.
*/
function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
const order = spec.order ?? [];
if (spec.signal !== 'logs' || order.some((o) => o.key?.name === 'id')) {
return envelope;
}
const primary =
order.length > 0
? order
: [
{
key: { name: 'timestamp' },
direction: Querybuildertypesv5OrderDirectionDTO.desc,
},
];
return {
...envelope,
spec: {
...envelope.spec,
order: [
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
/**
* Stamps offset/limit onto builder-query envelopes (server-side paging for raw/list); other
* kinds pass through.
@@ -269,9 +224,6 @@ export function buildQueryRangeRequest({
if (panelType === PANEL_TYPES.BAR) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (panelType === PANEL_TYPES.LIST) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
envelopes = withPagination(envelopes, pagination);
}

View File

@@ -51,23 +51,6 @@ const isBuilderQueryEnvelope = (
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
* pager. Dropped here, `limit` reflects only a real user limit and List panels page by default.
*/
const withoutExplorerPaging = (query: Query): Query => ({
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((data) => ({
...data,
pageSize: undefined,
offset: undefined,
})),
},
});
export function deriveQueryType(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): EQueryType {
@@ -137,11 +120,7 @@ export function toPerses(
query: Query,
panelType: PANEL_TYPES,
): DashboardtypesQueryDTO[] {
// List panels page server-side via usePanelQuery, so drop the V1 explorer's paging
// fields before conversion — otherwise the shared mapper folds them into `limit`.
const source =
panelType === PANEL_TYPES.LIST ? withoutExplorerPaging(query) : query;
const composite = mapCompositeQueryFromQuery(source, panelType);
const composite = mapCompositeQueryFromQuery(query, panelType);
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {

View File

@@ -2,7 +2,6 @@ import type {
Querybuildertypesv5AggregationBucketDTO,
Querybuildertypesv5ExecStatsDTO,
Querybuildertypesv5RawDataDTO,
Querybuildertypesv5RequestTypeDTO,
Querybuildertypesv5ScalarDataDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
@@ -45,13 +44,6 @@ export function getRawResults(
return (data.data?.results ?? []) as Querybuildertypesv5RawDataDTO[];
}
/** Response request-type discriminator (raw/trace/scalar/time_series); detects a stale cross-type response. */
export function getResponseType(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5RequestTypeDTO | undefined {
return response?.data?.type;
}
/** Exec stats (incl. per-query `stepIntervals`) from the response top level. */
export function getExecStats(
response: QueryRangeV5200 | undefined,

View File

@@ -13,7 +13,7 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import {
@@ -49,7 +49,7 @@ function PanelEditorPage(): JSX.Element {
newKind
? createDefaultPanel(
newKind,
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
buildDefaultQueries(newKind),
)
: existingPanel,

View File

@@ -26,19 +26,3 @@
padding: 0px;
}
}
.renameFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Wrap so the tooltip has a hoverable target even when the button is disabled. */
.menuItemWrap {
display: block;
width: 100%;
}
.menuItemWrap button:disabled {
pointer-events: none;
}

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { useMutation } from 'react-query';
import { generatePath } from 'react-router-dom';
import { Popover, Tooltip } from 'antd';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import {
@@ -9,28 +8,18 @@ import {
Expand,
EllipsisVertical,
Link2,
LockKeyhole,
PenLine,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import {
cloneDashboardV2,
invalidateListDashboardsForUserV2,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import RenameDashboardModal from './RenameDashboardModal';
import styles from './ActionsPopover.module.scss';
interface Props {
@@ -53,7 +42,6 @@ function ActionsPopover({
const [, setCopy] = useCopyToClipboard();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isRenameOpen, setIsRenameOpen] = useState(false);
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
// by the caller; open the copy so it can be tweaked right away.
@@ -70,159 +58,85 @@ function ActionsPopover({
},
});
const queryClient = useQueryClient();
const { user } = useAppContext();
const isAuthor = user?.email === createdBy;
// Author/admin can lock-unlock (mirrors the detail-page gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) && createdBy !== 'integration';
const { mutate: runLockToggle, isLoading: isTogglingLock } = useMutation({
mutationFn: () =>
isLocked
? unlockDashboardV2({ id: dashboardId })
: lockDashboardV2({ id: dashboardId }),
onSuccess: async () => {
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
await invalidateListDashboardsForUserV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
return (
<>
<Popover
content={
// Stop clicks inside the menu (incl. disabled items) from bubbling to the
// row's onClick, which would navigate to the dashboard.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- wrapper only guards propagation, not an interactive control
<div className={styles.content} onClick={(e): void => e.stopPropagation()}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
}}
testId="dashboard-action-rename"
>
Rename
</Button>
</span>
</Tooltip>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
{canToggleLock && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<LockKeyhole size={14} />}
loading={isTogglingLock}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runLockToggle();
}}
testId="dashboard-action-lock"
>
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
)}
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
<Popover
content={
<div className={styles.content}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<EllipsisVertical size={14} />
</Button>
</Popover>
<RenameDashboardModal
open={isRenameOpen}
dashboardId={dashboardId}
currentName={dashboardName}
onClose={(): void => setIsRenameOpen(false)}
/>
</>
<EllipsisVertical size={14} />
</Button>
</Popover>
);
}

View File

@@ -7,10 +7,8 @@ import { CircleAlert, Trash2 } from '@signozhq/icons';
import { toast } from '@signozhq/ui/sonner';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import {
deleteDashboardV2,
invalidateListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import deleteDashboard from 'api/v1/dashboards/id/delete';
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -41,12 +39,12 @@ function DeleteActionItem({
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
const { mutate: runDelete } = useMutation({
mutationFn: () => deleteDashboardV2({ id: dashboardId }),
mutationFn: () => deleteDashboard({ id: dashboardId }),
onSuccess: async () => {
toast.success(
t('dashboard:delete_dashboard_success', { name: dashboardName }),
);
await invalidateListDashboardsForUserV2(queryClient);
await invalidateListDashboardsV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
@@ -54,7 +52,7 @@ function DeleteActionItem({
});
const openConfirm = useCallback((): void => {
modal.confirm({
const { destroy } = modal.confirm({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
@@ -72,13 +70,14 @@ function DeleteActionItem({
/>
),
okText: 'Delete',
okButtonProps: { danger: true },
// Returning a promise keeps the Delete button in a loading state and blocks
// re-clicks until the mutation settles, then closes the confirm.
onOk: () =>
new Promise<void>((resolve) => {
runDelete(undefined, { onSettled: () => resolve() });
}),
okButtonProps: {
danger: true,
onClick: (e): void => {
e.preventDefault();
e.stopPropagation();
runDelete(undefined, { onSettled: () => destroy() });
},
},
cancelButtonProps: {
onClick: (e): void => {
e.stopPropagation();
@@ -102,25 +101,23 @@ function DeleteActionItem({
<>
<Divider />
<Tooltip placement="left" title={tooltip}>
<span className={styles.menuItemWrap}>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</span>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</Tooltip>
{contextHolder}
</>

View File

@@ -1,118 +0,0 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
// eslint-disable-next-line no-restricted-imports -- list rename targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './ActionsPopover.module.scss';
interface Props {
open: boolean;
dashboardId: string;
currentName: string;
onClose: () => void;
}
/** Renames a dashboard from the list via a `/spec/display/name` patch, then refreshes the list. */
function RenameDashboardModal({
open,
dashboardId,
currentName,
onClose,
}: Props): JSX.Element {
const [name, setName] = useState(currentName);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
// Reset the field to the current name whenever the modal (re)opens.
useEffect(() => {
if (open) {
setName(currentName);
}
}, [open, currentName]);
const { mutate: runRename, isLoading } = useMutation({
mutationFn: () => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: name.trim(),
},
];
return patchDashboardV2({ id: dashboardId }, ops);
},
onSuccess: async () => {
toast.success('Dashboard renamed');
await invalidateListDashboardsForUserV2(queryClient);
onClose();
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
const trimmed = name.trim();
const canSave = trimmed.length > 0 && trimmed !== currentName && !isLoading;
return (
<DialogWrapper
title="Rename dashboard"
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.renameFooter}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="rename-dashboard-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
disabled={!canSave}
loading={isLoading}
onClick={(): void => runRename()}
testId="rename-dashboard-submit"
>
Save
</Button>
</div>
}
>
<Input
value={name}
autoFocus
placeholder="Dashboard name"
testId="rename-dashboard-input"
onChange={(e): void => setName(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' && canSave) {
runRename();
}
}}
/>
</DialogWrapper>
);
}
export default RenameDashboardModal;

View File

@@ -52,7 +52,6 @@
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.tagsWithActions {
@@ -63,15 +62,7 @@
justify-content: flex-end;
}
.lockIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--l3-foreground);
}
.pinButton {
.pinBtn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -88,16 +79,16 @@
color 0.12s;
}
.row:hover .pinButton {
.row:hover .pinBtn {
color: var(--l3-foreground);
}
.pinButton:hover {
.pinBtn:hover {
background: var(--l1-background);
color: var(--bg-amber-500);
}
.pinButtonOn {
.pinBtnOn {
color: var(--bg-amber-500);
svg {
@@ -105,24 +96,6 @@
}
}
/* Pinned rows show the filled pin by default; the unpin action only appears when
the pin button itself is hovered. */
.pinnedIcon {
display: inline-flex;
}
.unpinIcon {
display: none;
}
.pinButton:hover .pinnedIcon {
display: none;
}
.pinButton:hover .unpinIcon {
display: inline-flex;
}
.tags {
display: flex;
flex-wrap: wrap;
@@ -209,3 +182,10 @@
align-items: center;
gap: 6px;
}
:global(.titleTooltipOverlay) {
:global(.ant-tooltip-content) :global(.ant-tooltip-inner) {
max-height: 400px;
overflow: auto;
}
}

View File

@@ -1,8 +1,7 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CalendarClock, LockKeyhole, Pin, PinOff } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
@@ -74,31 +73,25 @@ function DashboardRow({
togglePin(id, isPinned);
};
// Only long titles are truncated, so only they need the full-name tooltip;
// wrapping conditionally avoids an empty hanging tooltip for short names.
const titleLink = (
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
);
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
{name.length > 50 ? (
<TooltipSimple title={name} side="bottom" disableHoverableContent>
{titleLink}
</TooltipSimple>
) : (
titleLink
)}
<Tooltip
title={name.length > 50 ? name : ''}
placement="left"
overlayClassName="titleTooltipOverlay"
>
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
</Tooltip>
</div>
<div className={styles.tagsWithActions}>
@@ -118,44 +111,17 @@ function DashboardRow({
)}
</div>
{isLocked && (
<TooltipSimple
title="This dashboard is locked"
side="top"
disableHoverableContent
>
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
<LockKeyhole size={14} />
</span>
</TooltipSimple>
)}
<TooltipSimple
<button
type="button"
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
side="top"
disableHoverableContent
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.pinButton, { [styles.pinButtonOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
{isPinned ? (
<>
<Pin size={14} className={styles.pinnedIcon} />
<PinOff size={14} className={styles.unpinIcon} />
</>
) : (
<Pin size={14} />
)}
</Button>
</TooltipSimple>
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
{canAct && (
<ActionsPopover

View File

@@ -102,8 +102,8 @@ function FilterZone({
/>
{!isEmpty && (
<Button
variant="outlined"
color="primary"
variant="ghost"
color="secondary"
size="sm"
prefix={<X size={12} />}
onClick={onClearAll}

View File

@@ -3,7 +3,7 @@ import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
import {
DashboardtypesListOrderDTO,
@@ -191,7 +191,7 @@ function ListHeader({
aria-label="Columns"
testId="configure-columns-trigger"
>
<Columns3 size={14} />
<HdmiPort size={14} />
</Button>
</Tooltip>
</Popover>

View File

@@ -58,7 +58,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
variables: [],
},
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);

View File

@@ -20,11 +20,7 @@ import JsonEditor from './JsonEditor';
import styles from './NewDashboardModal.module.scss';
interface Props {
onClose: () => void;
}
function ImportJsonPanel({ onClose }: Props): JSX.Element {
function ImportJsonPanel(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation(['dashboard', 'common']);
const { showErrorModal } = useErrorModal();
@@ -63,7 +59,6 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
const response = await createDashboardV2(payload);
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);

View File

@@ -43,12 +43,12 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
{
key: 'template',
label: 'From a template',
children: <TemplatesPanel onClose={onClose} />,
children: <TemplatesPanel />,
},
{
key: 'import',
label: 'Import JSON',
children: <ImportJsonPanel onClose={onClose} />,
children: <ImportJsonPanel />,
},
]}
/>

View File

@@ -1,66 +1,136 @@
import { useState } from 'react';
import { generatePath } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { toast } from '@signozhq/ui/sonner';
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
import { AxiosError } from 'axios';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { openInNewTab } from 'utils/navigation';
import { normalizeToPostable } from './importUtils';
import JsonEditor from './JsonEditor';
import { useDashboardTemplates } from './templatesData';
import styles from './NewDashboardModal.module.scss';
interface Props {
onClose: () => void;
}
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
// so the action creates a blank dashboard.
function TemplatesPanel({ onClose }: Props): JSX.Element {
// Browse the template gallery (mock data until the API lands): pick one on the
// left to preview its JSON on the right, then use it or open the docs.
function TemplatesPanel(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { data, isLoading } = useDashboardTemplates(true);
const templates = data ?? [];
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const handleCreate = async (): Promise<void> => {
if (creating) {
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
const handleUse = async (): Promise<void> => {
if (!selected) {
return;
}
try {
setCreating(true);
logEvent('Dashboard List: Use template clicked', {});
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: 'Sample Dashboard' },
layouts: [],
panels: {},
variables: [],
},
});
onClose();
logEvent('Dashboard List: Use template clicked', { template: selected.id });
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
const created = await createDashboardV2(normalizeToPostable(parsed));
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
toast.error(
(e as AxiosError).toString() || 'Failed to create from template',
);
setCreating(false);
}
};
if (isLoading) {
return (
<div className={styles.panel}>
<div className={styles.loading}>
<LoaderCircle size={18} className={styles.spinner} />
<span>Loading templates</span>
</div>
</div>
);
}
return (
<div className={styles.panel}>
<div className="new-dashboard-templates-modal">
<DashboardTemplatesContent
onCreateNewDashboard={(): void => {
void handleCreate();
}}
/>
<div className={styles.templatesLayout}>
<div className={styles.templatesList}>
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cx(styles.templateItem, {
[styles.templateItemActive]: selected?.id === template.id,
})}
data-testid={`template-${template.id}`}
onClick={(): void => setSelectedId(template.id)}
>
<span className={styles.templateName}>{template.name}</span>
<span className={styles.templateCat}>{template.category}</span>
</button>
))}
</div>
{selected && (
<div className={styles.templatesPreview}>
<div className={styles.previewHead}>
<div>
<Typography.Text className={styles.cardName}>
{selected.name}
</Typography.Text>
<Typography.Text className={styles.cardDesc}>
{selected.description}
</Typography.Text>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ExternalLink size={13} />}
onClick={(): void => openInNewTab(selected.href)}
testId="template-docs"
>
Docs
</Button>
</div>
<JsonEditor value={selected.json} readOnly height="240px" />
<div className={styles.footer}>
<Button
variant="solid"
color="primary"
size="md"
loading={creating}
testId="use-template"
onClick={(): void => {
void handleUse();
}}
>
Use template
</Button>
</div>
</div>
)}
</div>
<div className={styles.requestRow}>
<RequestDashboardBtn />
</div>
</div>
);

View File

@@ -0,0 +1,106 @@
import { useQuery, type UseQueryResult } from 'react-query';
export interface DashboardTemplate {
id: string;
name: string;
description: string;
category: string;
href: string;
// Importable dashboard definition previewed in the gallery (mock for now).
json: string;
}
// A representative dashboard definition for a template — mock until the API
// returns real ones.
const buildTemplateJson = (
name: string,
description: string,
category: string,
): string =>
JSON.stringify(
{
schemaVersion: 'v6',
generateName: true,
tags: [{ key: 'category', value: category.toLowerCase() }],
spec: {
display: { name, description },
layouts: [],
panels: {},
variables: [],
},
},
null,
2,
);
// Mock catalogue until the templates API lands. Mirrors the public gallery at
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
{
id: 'apm',
name: 'APM Metrics',
description: 'Latency, error rate, and throughput across your services.',
category: 'APM',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
},
{
id: 'hostmetrics',
name: 'Host Metrics',
description: 'CPU, memory, disk, and network for your hosts.',
category: 'Infra',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
},
{
id: 'kubernetes',
name: 'Kubernetes Pod Metrics',
description: 'Pod, node, and container health for your clusters.',
category: 'Infra',
href:
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Connections, throughput, and query performance.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
},
{
id: 'redis',
name: 'Redis',
description: 'Memory, commands, and hit-rate for Redis instances.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
},
{
id: 'nginx',
name: 'NGINX',
description: 'Request rate, connections, and error responses.',
category: 'Web servers',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
},
];
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
...t,
json: buildTemplateJson(t.name, t.description, t.category),
}));
// TODO(@AshwinBhatkal): replace with the real templates API when available.
// The small delay simulates the network round-trip so the loading state is
// exercised (a real API call won't resolve instantly).
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
new Promise((resolve) => {
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
});
export function useDashboardTemplates(
enabled: boolean,
): UseQueryResult<DashboardTemplate[]> {
return useQuery({
queryKey: ['dashboard-templates'],
queryFn: fetchDashboardTemplates,
enabled,
staleTime: Infinity,
});
}

View File

@@ -54,17 +54,5 @@
}
.submit {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
}
.cmdHint {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: var(--l2-background);
}

View File

@@ -8,9 +8,8 @@ import {
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
import { CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import {
applyKeySuggestion,
@@ -41,8 +40,6 @@ function SearchBar({
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
@@ -58,11 +55,6 @@ function SearchBar({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit();
return;
}
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
@@ -98,7 +90,7 @@ function SearchBar({
<Button
variant="ghost"
color="secondary"
size="sm"
size="icon"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
@@ -108,15 +100,7 @@ function SearchBar({
}}
onClick={onSubmit}
>
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</Button>
}
value={value}

View File

@@ -306,7 +306,7 @@ function SaveView(): JSX.Element {
Manage your saved views for {ROUTES_VS_SOURCEPAGE[pathname]}.{' '}
<Typography.Link
className="learn-more"
href="https://signoz.io/docs/metrics-management/metrics-explorer/?utm_source=product&utm_medium=views-tab#saved-views-in-metrics-explorer"
href="https://signoz.io/docs/product-features/saved-view/?utm_source=product&utm_medium=views-tab"
target="_blank"
>
Learn more

View File

@@ -54,7 +54,7 @@ export default function ServiceTopLevelOperations(): JSX.Element {
SigNoz calculates the RED metrics for a service using the entry-point spans.
For more details, you can check out our
<a
href="https://signoz.io/docs/userguide/metrics/"
href="https://signoz.io/docs/userguide/metrics/#open-the-services-section"
target="_blank"
rel="noreferrer"
>

View File

@@ -867,7 +867,7 @@ function Success(props: ISuccessProps): JSX.Element {
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace',
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}

View File

@@ -1,15 +1,15 @@
const DOCLINKS = {
TRACES_EXPLORER_EMPTY_STATE:
'https://signoz.io/docs/instrumentation/overview/?utm_source=product&utm_medium=traces-explorer-empty-state',
USER_GUIDE: 'https://signoz.io/docs/introduction/',
USER_GUIDE: 'https://signoz.io/docs/userguide/',
TRACES_DETAILS_LINK:
'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
METRICS_EXPLORER_EMPTY_STATE:
'https://signoz.io/docs/metrics-management/send-metrics/',
'https://signoz.io/docs/userguide/send-metrics-cloud/',
EXTERNAL_API_MONITORING:
'https://signoz.io/docs/external-api-monitoring/overview/',
QUERY_CLICKHOUSE_TRACES:
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing',
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing-for-distributed_signoz_index_v3',
QUERY_CLICKHOUSE_LOGS:
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
QUERY_CLICKHOUSE_METRICS:

View File

@@ -39,48 +39,48 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_sum_latest",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationLatest, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
{
name: "gauge_avg_avg",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
{
name: "gauge_min_min",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMin, metrictypes.SpaceAggregationMin),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`min`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`min`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
{
name: "gauge_max_max",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMax, metrictypes.SpaceAggregationMax),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`max`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`max`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
{
name: "counter_sum_rate",
query: reducedQuery("test.metric.sum", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false},
},
},
{
name: "counter_avg_increase",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationIncrease, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), false, "test.metric", uint64(1746999600000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), "test.metric", uint64(1746999600000), uint64(1747172760000), false},
},
},
{
@@ -103,16 +103,16 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "histogram_p99",
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, `le`, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli, `le`) GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false},
},
},
{
name: "summary_avg",
query: reducedQuery("test.metric", metrictypes.SummaryType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
}

View File

@@ -338,24 +338,19 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
// dedup recomputed buckets: latest computed_at wins per (series, 60s bucket)
dedup := sqlbuilder.NewSelectBuilder()
dedup.Select("points.reduced_fingerprint AS fingerprint", "points.unix_milli AS unix_milli")
for _, g := range query.GroupBy {
dedup.SelectMore(fmt.Sprintf("`%s`", g.Name))
}
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS value", value))
dedup.Select("reduced_fingerprint AS fingerprint", "unix_milli")
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS value", value))
if weight != "" {
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS weight", weight))
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS weight", weight))
}
dedup.From(fmt.Sprintf("%s.%s AS points", DBName, WhichReducedSamplesTableToUse(agg.Type)))
dedup.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
dedup.From(fmt.Sprintf("%s.%s", DBName, WhichReducedSamplesTableToUse(agg.Type)))
dedup.Where(
dedup.In("metric_name", agg.MetricName),
dedup.GTE("unix_milli", start),
dedup.LT("unix_milli", end),
)
dedup.GroupBy("fingerprint", "unix_milli")
dedup.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
dedup.GroupBy("reduced_fingerprint", "unix_milli")
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("fingerprint")
@@ -369,11 +364,13 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
// denominator is reduced with avg
sb.SelectMore("avg(weight) AS per_series_weight")
}
sb.From(fmt.Sprintf("(%s)", dedupQuery))
sb.From(fmt.Sprintf("(%s) AS points", dedupQuery))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.GroupBy("fingerprint", "ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, dedupArgs...)
initArgs := append(append([]any{}, dedupArgs...), timeSeriesCTEArgs...)
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, initArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, true
}

View File

@@ -10,6 +10,10 @@ import {
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -366,6 +370,36 @@ export async function findDashboardIdByTitle(
return body.data.find((d) => d.data.title === title)?.id;
}
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
export interface DashboardData {
title: string;
description?: string;
tags?: string[];
widgets?: Array<{ id?: string; title?: string }>;
variables?: Record<string, Record<string, unknown>>;
layout?: unknown[];
}
/**
* Fetch the persisted dashboard payload via API. Use this for "did the save
* actually land on the server?" assertions — UI-only checks can pass on
* optimistic-update bugs.
*/
export async function fetchDashboardData(
page: Page,
id: string,
): Promise<DashboardData> {
const token = await authToken(page);
const res = await page.request.get(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`GET /dashboards/${id} ${res.status()}: ${await res.text()}`);
}
const body = (await res.json()) as { data: { data: DashboardData } };
return body.data.data;
}
// ─── List page UI helpers ────────────────────────────────────────────────
/**
@@ -387,3 +421,142 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(
page: Page,
): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes and wait for the PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page
.getByTestId('metric-name-selector-0')
.locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}

12
tests/e2e/testdata/queries.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -2,6 +2,7 @@ import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
@@ -143,4 +144,59 @@ test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () =
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
});
// ─── Per-signal panel creation ───────────────────────────────────────────
//
// Configure a query for each signal using values from testdata/queries.json,
// save the panel, return to the dashboard, and verify the panel card renders
// and survives a reload.
test('TC-03 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-metrics');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
// Reload — proves the panel persists, not just optimistic UI from the save.
await page.reload();
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-04 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-logs');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-05 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'add-panel-traces');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
await page.reload();
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});

View File

@@ -7,6 +7,10 @@ import {
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
fetchDashboardData,
gotoDashboardsList,
renameDashboardViaToolbar,
SEARCH_PLACEHOLDER,
} from '../../../helpers/dashboards';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
@@ -684,4 +688,38 @@ test.describe('Dashboard Detail — Configure drawer', () => {
.first(),
).toBeVisible();
});
// ─── Toolbar rename ──────────────────────────────────────────────────────
// The toolbar options popover uses a separate PUT path from the Configure
// drawer (TC-02 above). Both paths must persist server-side and surface in
// the list view — this catches optimistic-update regressions specific to
// the toolbar entry point.
test('TC-22 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-toolbar-rename');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'cfg-toolbar-rename-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'cfg-toolbar-rename-renamed',
);
const persisted = await fetchDashboardData(page, id);
expect(persisted.title).toBe('cfg-toolbar-rename-renamed');
// List view reflects the rename after navigating back.
await gotoDashboardsList(page);
await page
.getByPlaceholder(SEARCH_PLACEHOLDER)
.fill('cfg-toolbar-rename-renamed');
await expect(
page.getByText('cfg-toolbar-rename-renamed').first(),
).toBeVisible();
});
});

View File

@@ -9,6 +9,7 @@ import {
createDashboardViaApi,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
openDashboardSettingsDrawer,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
@@ -240,4 +241,25 @@ test.describe('Dashboard Detail Page — Edge Cases', () => {
// document.title is set from the dashboard name — confirm it is intact.
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
});
test('TC-09 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await createDashboardViaApi(page, 'edge-drawer-nav-away');
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
});

View File

@@ -1,3 +1,4 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
@@ -8,6 +9,7 @@ import {
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
fetchDashboardData,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
@@ -15,6 +17,11 @@ import {
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
@@ -422,12 +429,140 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
test('TC-19 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog
.locator('input[type="file"]')
.setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(
APM_METRICS_TITLE,
);
// Server-side check: every widget + tag from the fixture must be persisted.
// A partial import (e.g. silently dropped widgets) would pass the UI title
// check but fail here. The apm-metrics fixture has 16 widgets and 4 tags.
const persisted = await fetchDashboardData(page, urlMatch![1]);
expect(persisted.widgets?.length).toBe(16);
expect(persisted.tags).toEqual(
expect.arrayContaining(['apm', 'latency', 'error rate', 'throughput']),
);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// The Monaco paste path is intentionally not covered — the file-upload
// path (TC-19) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-20 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
// Track POST attempts: invalid JSON must never reach the create endpoint.
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
await page.waitForLoadState('networkidle');
expect(postFired, 'invalid JSON must not trigger POST').toBe(false);
});
test('TC-21 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
let postFired = false;
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
if (route.request().method() === 'POST') {
postFired = true;
}
void route.continue();
});
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.waitForLoadState('networkidle');
expect(postFired, 'empty editor must not trigger POST').toBe(false);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
test('TC-22 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
@@ -456,7 +591,7 @@ test.describe('Dashboards List Page', () => {
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
test('TC-23 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
@@ -471,7 +606,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-21 confirming delete removes the dashboard from the list', async ({
test('TC-24 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
@@ -506,7 +641,7 @@ test.describe('Dashboards List Page', () => {
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
test('TC-25 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
@@ -520,7 +655,7 @@ test.describe('Dashboards List Page', () => {
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
test('TC-26 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
@@ -538,7 +673,7 @@ test.describe('Dashboards List Page', () => {
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
test('TC-27 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
@@ -557,7 +692,7 @@ test.describe('Dashboards List Page', () => {
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-25 direct navigation with sort params honours them on load', async ({
test('TC-28 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');

View File

@@ -2,7 +2,6 @@ import os
from collections.abc import Callable, Generator
from datetime import datetime
from typing import Any
from uuid import uuid4
import clickhouse_connect
import clickhouse_connect.driver
@@ -11,93 +10,37 @@ import docker
import docker.errors
import pytest
from testcontainers.clickhouse import ClickHouseContainer
from testcontainers.core.container import DockerContainer, Network
from testcontainers.core.container import Network
from fixtures import reuse, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLICKHOUSE_USERNAME = "signoz"
CLICKHOUSE_PASSWORD = "password"
CUSTOM_FUNCTION_CONFIG = """
<functions>
<function>
<type>executable</type>
<name>histogramQuantile</name>
<return_type>Float64</return_type>
<argument>
<type>Array(Float64)</type>
<name>buckets</name>
</argument>
<argument>
<type>Array(Float64)</type>
<name>counts</name>
</argument>
<argument>
<type>Float64</type>
<name>quantile</name>
</argument>
<format>CSV</format>
<command>./histogramQuantile</command>
</function>
</functions>
"""
# Distributed inserts to a remote shard are async by default. We force
# sycn at the profile level for deterministic tests.
CLUSTER_USERS_CONFIG = """
<clickhouse>
<profiles>
<default>
<insert_distributed_sync>1</insert_distributed_sync>
</default>
</profiles>
</clickhouse>
"""
def render_remote_servers(shard_hosts: list[tuple[str, int]], secret: str | None = None) -> str:
"""Render the <remote_servers> block for a cluster named `cluster` with one
single-replica shard per (host, port).
@pytest.fixture(name="clickhouse", scope="package")
def clickhouse(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
zookeeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerClickhouse:
"""
Package-scoped fixture for Clickhouse TestContainer.
"""
shards = "".join(
f"""
<shard>
<replica>
<host>{host}</host>
<port>{port}</port>
</replica>
</shard>"""
for host, port in shard_hosts
)
# Multi-node clusters need `secret` because distributed queries otherwise
# authenticate as the `default` user, which the docker entrypoint restricts
# to localhost when a custom user is configured.
secret_block = (
f"""
<secret>{secret}</secret>"""
if secret
else ""
)
def create() -> types.TestContainerClickhouse:
version = request.config.getoption("--clickhouse-version")
return f"""
<remote_servers>
<cluster>{secret_block}{shards}
</cluster>
</remote_servers>"""
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{version}",
port=9000,
username="signoz",
password="password",
)
def render_node_config(
zookeeper_address: str,
zookeeper_port: int,
shard: str,
remote_servers: str,
distributed_ddl_path: str = "/clickhouse/task_queue/ddl",
) -> str:
return f"""
cluster_config = f"""
<clickhouse>
<logger>
<level>information</level>
@@ -112,23 +55,33 @@ def render_node_config(
</logger>
<macros>
<shard>{shard}</shard>
<shard>01</shard>
<replica>01</replica>
</macros>
<zookeeper>
<node>
<host>{zookeeper_address}</host>
<port>{zookeeper_port}</port>
<host>{zookeeper.container_configs["2181"].address}</host>
<port>{zookeeper.container_configs["2181"].port}</port>
</node>
</zookeeper>
{remote_servers}
<remote_servers>
<cluster>
<shard>
<replica>
<host>127.0.0.1</host>
<port>9000</port>
</replica>
</shard>
</cluster>
</remote_servers>
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
<distributed_ddl>
<path>{distributed_ddl_path}</path>
<path>/clickhouse/task_queue/ddl</path>
<profile>default</profile>
</distributed_ddl>
@@ -169,66 +122,38 @@ def render_node_config(
</clickhouse>
"""
custom_function_config = """
<functions>
<function>
<type>executable</type>
<name>histogramQuantile</name>
<return_type>Float64</return_type>
<argument>
<type>Array(Float64)</type>
<name>buckets</name>
</argument>
<argument>
<type>Array(Float64)</type>
<name>counts</name>
</argument>
<argument>
<type>Float64</type>
<name>quantile</name>
</argument>
<format>CSV</format>
<command>./histogramQuantile</command>
</function>
</functions>
"""
def install_histogram_quantile(container: ClickHouseContainer) -> None:
wrapped = container.get_wrapped_container()
exit_code, output = wrapped.exec_run(
[
"bash",
"-c",
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "
"mkdir -p /var/lib/clickhouse/user_scripts && "
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
),
],
)
if exit_code != 0:
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional-arguments
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
keeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhouse",
version: str | None = None,
) -> types.TestContainerClickhouse:
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
clickhouse_version = version or request.config.getoption("--clickhouse-version")
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
port=9000,
username=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
)
cluster_config = render_node_config(
zookeeper_address=coordinator.address,
zookeeper_port=coordinator.port,
shard="01",
remote_servers=render_remote_servers([("127.0.0.1", 9000)]),
)
tmp_dir = tmpfs(cache_key)
tmp_dir = tmpfs("clickhouse")
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(cluster_config)
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
with open(custom_function_file_path, "w", encoding="utf-8") as f:
f.write(CUSTOM_FUNCTION_CONFIG)
f.write(custom_function_config)
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
container.with_volume_mapping(
@@ -238,7 +163,27 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
container.with_network(network)
container.start()
install_histogram_quantile(container)
# Download and install the histogramQuantile binary
wrapped = container.get_wrapped_container()
exit_code, output = wrapped.exec_run(
[
"bash",
"-c",
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "
"mkdir -p /var/lib/clickhouse/user_scripts && "
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
),
],
)
if exit_code != 0:
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
connection = clickhouse_connect.get_client(
user=container.username,
@@ -308,7 +253,7 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
return reuse.wrap(
request,
pytestconfig,
cache_key,
"clickhouse",
empty=lambda: types.TestContainerSQL(
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
conn=None,
@@ -320,334 +265,6 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
)
@pytest.fixture(name="clickhouse", scope="package")
def clickhouse(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
zookeeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerClickhouse:
"""
Package-scoped fixture for Clickhouse TestContainer.
"""
return create_clickhouse(
tmpfs=tmpfs,
network=network,
keeper=zookeeper,
request=request,
pytestconfig=pytestconfig,
)
def local_series_counts(
node_conns: list[clickhouse_connect.driver.client.Client],
table: str,
metric_name: str,
) -> list[int]:
"""Distinct series per node via the LOCAL (non-distributed) table."""
return [
int(
conn.query(
f"SELECT count(DISTINCT fingerprint) FROM signoz_metrics.{table} WHERE metric_name = %(metric_name)s",
parameters={"metric_name": metric_name},
).result_rows[0][0]
)
for conn in node_conns
]
def assert_spans_shards(
node_conns: list[clickhouse_connect.driver.client.Client],
table: str,
metric_name: str,
total: int,
) -> None:
"""Guard for distributed tests: a green run on a cluster proves nothing
unless the seeded series actually landed on more than one shard."""
counts = local_series_counts(node_conns, table, metric_name)
assert sum(counts) == total, f"expected {total} series in {table} across shards, got {counts}"
assert min(counts) > 0, f"seeded series in {table} all landed on one shard: {counts}"
@pytest.fixture(name="clickhouse_node_conns", scope="function")
def clickhouse_node_conns(
clickhouse: types.TestContainerClickhouse,
) -> Generator[list[clickhouse_connect.driver.client.Client], Any]:
"""Per-node clients (index 0 = the initiator) for asserting shard-local
state via the local, non-distributed tables. Empty for single-node
fixtures, which don't populate `nodes`."""
conns = [
clickhouse_connect.get_client(
user=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
password=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
host=node.host_configs["8123"].address,
port=node.host_configs["8123"].port,
)
for node in clickhouse.nodes
]
yield conns
for conn in conns:
conn.close()
KEEPER_CONFIG = """
<clickhouse>
<listen_host>0.0.0.0</listen_host>
<keeper_server>
<tcp_port>9181</tcp_port>
<server_id>1</server_id>
<log_storage_path>/var/lib/clickhouse-keeper/coordination/log</log_storage_path>
<snapshot_storage_path>/var/lib/clickhouse-keeper/coordination/snapshots</snapshot_storage_path>
<coordination_settings>
<operation_timeout_ms>10000</operation_timeout_ms>
<session_timeout_ms>30000</session_timeout_ms>
<raft_logs_level>warning</raft_logs_level>
</coordination_settings>
<raft_configuration>
<server>
<id>1</id>
<hostname>localhost</hostname>
<port>9234</port>
</server>
</raft_configuration>
</keeper_server>
</clickhouse>
"""
def create_clickhouse_keeper(
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhousekeeper",
version: str | None = None,
) -> types.TestContainerDocker:
def create() -> types.TestContainerDocker:
keeper_version = version or request.config.getoption("--clickhouse-version")
tmp_dir = tmpfs(cache_key)
keeper_config_file_path = os.path.join(tmp_dir, "keeper_config.xml")
with open(keeper_config_file_path, "w", encoding="utf-8") as f:
f.write(KEEPER_CONFIG)
container = DockerContainer(image=f"clickhouse/clickhouse-keeper:{keeper_version}")
container.with_volume_mapping(keeper_config_file_path, "/etc/clickhouse-keeper/keeper_config.xml")
container.with_exposed_ports(9181)
container.with_network(network=network)
container.start()
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"9181": types.TestContainerUrlConfig(
scheme="tcp",
address=container.get_container_host_ip(),
port=container.get_exposed_port(9181),
)
},
container_configs={
"9181": types.TestContainerUrlConfig(
scheme="tcp",
address=container.get_wrapped_container().name,
port=9181,
)
},
)
def delete(container: types.TestContainerDocker):
client = docker.from_env()
try:
client.containers.get(container_id=container.id).stop()
client.containers.get(container_id=container.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of ClickHouse Keeper, Keeper(%s) not found. Maybe it was manually removed?",
{"id": container.id},
)
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
return reuse.wrap(
request,
pytestconfig,
cache_key,
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
restore,
)
def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments
tmpfs: Generator[types.LegacyPath, Any],
network: Network,
keeper: types.TestContainerDocker,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "clickhouse_cluster",
shards: int = 2,
version: str | None = None,
) -> types.TestContainerClickhouse:
"""
To some extent, taken inspiration from how ClickHouse's own integration
harness composes real clusters: deterministic hostnames
(network aliases), per-node shard macros, and a shared cluster definition
named `cluster`.
`conn`/`env` point at node 1 i.e the initiator every query-service query and
migration goes through. Per-node containers are exposed via `nodes` so
tests can assert shard-local state. `keeper` is any coordination service
(ZooKeeper or ClickHouse Keeper).
"""
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
clickhouse_version = version or request.config.getoption("--clickhouse-version")
# Unique aliases per creation: docker allows duplicate network aliases
# (DNS round-robin), so a stale cluster must never share names with a
# fresh one.
suffix = uuid4().hex[:6]
aliases = [f"signoz-ch-{suffix}-{i:02d}" for i in range(1, shards + 1)]
remote_servers = render_remote_servers([(alias, 9000) for alias in aliases], secret=cache_key)
# Own DDL queue path: the keeper instance may be shared with other
# environments under --reuse; its DDL queue stays separate.
distributed_ddl_path = f"/clickhouse/{cache_key}-{suffix}/task_queue/ddl"
nodes: list[types.TestContainerDocker] = []
started: list[ClickHouseContainer] = []
try:
for i, alias in enumerate(aliases, start=1):
node_config = render_node_config(
zookeeper_address=coordinator.address,
zookeeper_port=coordinator.port,
shard=f"{i:02d}",
remote_servers=remote_servers,
distributed_ddl_path=distributed_ddl_path,
)
tmp_dir = tmpfs(f"clickhouse-{suffix}-{i:02d}")
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(node_config)
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
with open(custom_function_file_path, "w", encoding="utf-8") as f:
f.write(CUSTOM_FUNCTION_CONFIG)
users_config_file_path = os.path.join(tmp_dir, "users.xml")
with open(users_config_file_path, "w", encoding="utf-8") as f:
f.write(CLUSTER_USERS_CONFIG)
container = ClickHouseContainer(
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
port=9000,
username=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
)
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
container.with_volume_mapping(custom_function_file_path, "/etc/clickhouse-server/custom-function.xml")
container.with_volume_mapping(users_config_file_path, "/etc/clickhouse-server/users.d/integration-cluster.xml")
container.with_network(network)
container.with_network_aliases(alias)
container.start()
started.append(container)
install_histogram_quantile(container)
nodes.append(
types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"9000": types.TestContainerUrlConfig(
"tcp",
container.get_container_host_ip(),
container.get_exposed_port(9000),
),
"8123": types.TestContainerUrlConfig(
"tcp",
container.get_container_host_ip(),
container.get_exposed_port(8123),
),
},
container_configs={
"9000": types.TestContainerUrlConfig("tcp", alias, 9000),
"8123": types.TestContainerUrlConfig("tcp", alias, 8123),
},
)
)
except Exception:
for container in started:
container.stop()
raise
connection = clickhouse_connect.get_client(
user=CLICKHOUSE_USERNAME,
password=CLICKHOUSE_PASSWORD,
host=nodes[0].host_configs["8123"].address,
port=nodes[0].host_configs["8123"].port,
)
return types.TestContainerClickhouse(
container=nodes[0],
conn=connection,
env={
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": f"tcp://{CLICKHOUSE_USERNAME}:{CLICKHOUSE_PASSWORD}@{aliases[0]}:{9000}",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME": CLICKHOUSE_USERNAME,
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD": CLICKHOUSE_PASSWORD,
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
},
nodes=nodes,
)
def delete(resource: types.TestContainerClickhouse) -> None:
client = docker.from_env()
for node in resource.nodes or [resource.container]:
try:
client.containers.get(container_id=node.id).stop()
client.containers.get(container_id=node.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of Clickhouse cluster node, node(%s) not found. Maybe it was manually removed?",
{"id": node.id},
)
def restore(cache: dict) -> types.TestContainerClickhouse:
nodes = [types.TestContainerDocker.from_cache(node) for node in cache["nodes"]]
env = cache["env"]
host_config = nodes[0].host_configs["8123"]
conn = clickhouse_connect.get_client(
user=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
password=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
host=host_config.address,
port=host_config.port,
)
return types.TestContainerClickhouse(
container=nodes[0],
conn=conn,
env=env,
nodes=nodes,
)
return reuse.wrap(
request,
pytestconfig,
cache_key,
empty=lambda: types.TestContainerClickhouse(
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
conn=None,
env={},
),
create=create,
delete=delete,
restore=restore,
)
@pytest.fixture(name="check_query_log")
def check_query_log(
signoz: types.SigNoz,

View File

@@ -18,22 +18,19 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
ZEUS_NETWORK_ALIAS = "signoz-zeus-it"
def create_zeus(
@pytest.fixture(name="zeus", scope="package")
def zeus(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "zeus",
alias: str | None = None,
) -> types.TestContainerDocker:
"""
Package-scoped fixture for running zeus
"""
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
if alias:
container.with_network_aliases(alias)
container.start()
return types.TestContainerDocker(
@@ -45,7 +42,7 @@ def create_zeus(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", alias or container.get_wrapped_container().name, 8080)},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):
@@ -65,7 +62,7 @@ def create_zeus(
return reuse.wrap(
request,
pytestconfig,
cache_key,
"zeus",
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
@@ -73,18 +70,6 @@ def create_zeus(
)
@pytest.fixture(name="zeus", scope="package")
def zeus(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerDocker:
"""
Package-scoped fixture for running zeus
"""
return create_zeus(network=network, request=request, pytestconfig=pytestconfig)
@pytest.fixture(name="gateway", scope="package")
def gateway(
network: Network,

View File

@@ -1,51 +0,0 @@
import datetime
from collections.abc import Sequence
from fixtures.metrics import MetricsBufferSample, MetricsBufferTimeSeries
def build_ruled_gauge_buffer(
metric_name: str,
base_epoch: int,
services: Sequence[str],
pods_per_service: int,
minutes: int,
value: float = 1.0,
) -> tuple[list[MetricsBufferTimeSeries], list[MetricsBufferSample]]:
"""Collector-shaped buffer rows for a gauge under a reduction rule that
keeps `service`: per raw series a raw series row (is_reduced=false, full
labels, reduced_fingerprint -> group) plus the group's reduced series row
(is_reduced=true, kept labels), and one raw sample per series per minute
carrying both fingerprints. Returns (time_series, samples) for
insert_buffer_metrics."""
reduced_series = {
service: MetricsBufferTimeSeries(
metric_name=metric_name,
labels={"service": service},
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
is_reduced=True,
)
for service in services
}
raw_series = [
MetricsBufferTimeSeries(
metric_name=metric_name,
labels={"service": service, "pod": f"pod-{service}-{i}"},
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
reduced_fingerprint=reduced_series[service].fingerprint,
)
for service in services
for i in range(pods_per_service)
]
samples = [
MetricsBufferSample(
metric_name=metric_name,
fingerprint=ts.fingerprint,
timestamp=datetime.datetime.fromtimestamp(base_epoch + minute * 60, tz=datetime.UTC),
value=value,
reduced_fingerprint=ts.reduced_fingerprint,
)
for ts in raw_series
for minute in range(minutes)
]
return raw_series + list(reduced_series.values()), samples

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