mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 14:40:38 +01:00
Compare commits
7 Commits
e2e/dashbo
...
issue-5535
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6bb7569af | ||
|
|
5d431f9f6f | ||
|
|
1f0113645e | ||
|
|
d490126162 | ||
|
|
526a7d1c46 | ||
|
|
9f72338c87 | ||
|
|
e7ef4c6bdb |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -56,6 +56,8 @@ jobs:
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
};
|
||||
|
||||
|
||||
@@ -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/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
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/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -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/userguide/query-builder-v5/#grouping',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#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/userguide/query-builder-v5/#conditional-filtering-with-having',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#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/userguide/query-builder-v5/#sorting--limiting',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#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/userguide/query-builder-v5/#sorting--limiting',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
{
|
||||
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/userguide/query-builder-v5/#legend-formatting',
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#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/#reduce-operations',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
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/userguide/query-builder-v5/#grouping"
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#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/userguide/query-builder-v5/#conditional-filtering-with-having"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#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/userguide/query-builder-v5/#sorting--limiting"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#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/#reduce-operations"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
|
||||
@@ -65,7 +65,7 @@ function QueryAggregationOptions({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -676,7 +676,7 @@ function QueryAggregationSelect({
|
||||
</span>
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
|
||||
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -44,7 +44,7 @@ function TraceOperatorSection({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add Trace Matching
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
|
||||
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/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
|
||||
@@ -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/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
export const apDexToolTipUrlText = 'Learn more about Apdex.';
|
||||
|
||||
@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
|
||||
<RightActionContainer>
|
||||
<TextToolTip
|
||||
text={t('tooltip_notification_channels')}
|
||||
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
|
||||
@@ -29,8 +29,7 @@ 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/?utm_source=product&utm_medium=alert-source-selection-page#examples';
|
||||
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
|
||||
break;
|
||||
case AlertTypes.METRICS_BASED_ALERT:
|
||||
url =
|
||||
|
||||
@@ -31,8 +31,7 @@ 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/?utm_source=product&utm_medium=alert-source-selection-page#examples',
|
||||
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
|
||||
creation:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
|
||||
@@ -717,13 +717,13 @@ function ExplorerOptions({
|
||||
|
||||
const infoIconLink = useMemo(() => {
|
||||
if (isLogsExplorer) {
|
||||
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
}
|
||||
// TODO: Add metrics explorer info icon link
|
||||
if (isMetricsExplorer) {
|
||||
return '';
|
||||
}
|
||||
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
}, [isLogsExplorer, isMetricsExplorer]);
|
||||
|
||||
const getQueryName = (query: Query): string => {
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function SavedViews({
|
||||
});
|
||||
|
||||
window.open(
|
||||
'https://signoz.io/docs/product-features/saved-view/',
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
|
||||
@@ -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/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
|
||||
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
|
||||
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
|
||||
SETUP_SAVED_VIEWS:
|
||||
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
|
||||
};
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function K8sEmptyState({
|
||||
<span className={styles.message}>
|
||||
Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/hostmetrics/"
|
||||
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/userguide/hostmetrics/',
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +1,9 @@
|
||||
/* 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 { Modal } from 'antd';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
import DashboardTemplatesContent from './DashboardTemplatesContent';
|
||||
|
||||
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;
|
||||
@@ -135,20 +15,6 @@ 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"
|
||||
@@ -159,75 +25,10 @@ export default function DashboardTemplatesModal({
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
>
|
||||
<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>
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={onCreateNewDashboard}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
|
||||
Meter metrics data is aggregated over 1 hour period. Please select time
|
||||
range accordingly.
|
||||
<a
|
||||
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
|
||||
href="https://signoz.io/docs/cost-meter/overview/#get-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
|
||||
@@ -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/traces-management/guides/entry-point-spans-service-overview/',
|
||||
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
|
||||
@@ -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/userguide/sso-authentication"
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
|
||||
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -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/userguide/sso-authentication"
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function QueryFunctions({
|
||||
Add new function
|
||||
<Typography.Link
|
||||
style={{ textDecoration: 'underline' }}
|
||||
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
|
||||
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
|
||||
target="_blank"
|
||||
>
|
||||
{' '}
|
||||
|
||||
@@ -100,7 +100,7 @@ function Version(): JSX.Element {
|
||||
{!isError && !isLatestVersion && (
|
||||
<div className="version-page-upgrade-container">
|
||||
<Button
|
||||
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
|
||||
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -7,6 +14,7 @@ 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';
|
||||
@@ -18,7 +26,13 @@ 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;
|
||||
@@ -33,7 +47,10 @@ function DashboardInfo({
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
publicUrl,
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -51,6 +68,17 @@ 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();
|
||||
@@ -101,7 +129,7 @@ function DashboardInfo({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<TooltipSimple title={title} disableHoverableContent>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
@@ -115,7 +143,7 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description}>
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
@@ -125,14 +153,44 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
{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>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
@@ -145,14 +203,14 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="warning"
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -15,9 +15,12 @@ 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';
|
||||
@@ -36,7 +39,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
// 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 title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -58,20 +69,36 @@ 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 (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
@@ -119,8 +146,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
import {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
@@ -77,7 +77,7 @@ function ThresholdsSection({
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? 'label';
|
||||
const variant = controls?.variant ?? ThresholdVariant.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);
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
ThresholdVariant,
|
||||
type AnyThreshold,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import UnifiedThresholdsSection from '../ThresholdsSection';
|
||||
@@ -21,7 +24,7 @@ function ComparisonThresholdsSection(props: {
|
||||
value={props.value}
|
||||
onChange={props.onChange as (next: AnyThreshold[]) => void}
|
||||
yAxisUnit={props.yAxisUnit}
|
||||
controls={{ variant: 'comparison' }}
|
||||
controls={{ variant: ThresholdVariant.COMPARISON }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
@@ -61,24 +61,42 @@ function Header({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
<DialogWrapper
|
||||
open={discard.open}
|
||||
onOpenChange={(next): void => {
|
||||
onOpenChange={(next: boolean): void => {
|
||||
if (!next) {
|
||||
discard.cancel();
|
||||
}
|
||||
}}
|
||||
title="Discard changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={discard.confirm}
|
||||
onCancel={discard.cancel}
|
||||
data-testid="panel-editor-v2-discard-modal"
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography>Your unsaved edits to this panel will be lost.</Typography>
|
||||
</ConfirmDialog>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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;
|
||||
@@ -43,6 +45,7 @@ function PreviewPane({
|
||||
panelDefinition,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
@@ -87,6 +90,7 @@ function PreviewPane({
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
@@ -28,20 +26,21 @@ 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('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
it("resolves the target kind's sections and carries the old spec through them", () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
sections: [
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
],
|
||||
});
|
||||
const old = specWith({ formatting: { unit: 'ms', decimalPrecision: 2 } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
@@ -49,25 +48,12 @@ 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('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', () => {
|
||||
it('forwards the signal to seed List columns', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
@@ -83,155 +69,4 @@ 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,149 +1,27 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} 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 {
|
||||
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';
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
export type SwitchedPluginSpec = SeededPluginSpec;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
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;
|
||||
return buildPluginSpec(getPanelDefinition(newKind).sections, {
|
||||
oldSpec,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ 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']
|
||||
@@ -131,7 +132,39 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
OrderByPayload,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
@@ -25,6 +28,25 @@ 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'];
|
||||
@@ -116,16 +138,21 @@ 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(transformed, newPanelType),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
@@ -102,12 +102,19 @@ function PanelEditorContainer({
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
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];
|
||||
@@ -235,6 +242,7 @@ function PanelEditorContainer({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
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).
|
||||
@@ -10,6 +14,9 @@ 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: 'label' } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Select, Table } from 'antd';
|
||||
import { Select, Skeleton, Table } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
@@ -36,6 +36,7 @@ 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);
|
||||
@@ -115,6 +116,25 @@ 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}
|
||||
@@ -134,13 +154,13 @@ function ListPanelRenderer({
|
||||
<Table
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
columns={resizableColumns}
|
||||
columns={isPreviousData ? skeletonColumns : resizableColumns}
|
||||
components={components}
|
||||
dataSource={filteredDataSource}
|
||||
dataSource={isPreviousData ? skeletonRows : filteredDataSource}
|
||||
pagination={false}
|
||||
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
|
||||
scroll={{ y: scrollY }}
|
||||
onRow={onRow}
|
||||
onRow={isPreviousData ? undefined : onRow}
|
||||
/>
|
||||
</div>
|
||||
{showPager && pagination && (
|
||||
|
||||
@@ -162,4 +162,18 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -6,6 +10,9 @@ export const sections: SectionConfig[] = [
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.COMPARISON },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
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 +
|
||||
@@ -12,6 +16,9 @@ export const sections: SectionConfig[] = [
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'table' } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.TABLE },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -18,6 +22,9 @@ export const sections: SectionConfig[] = [
|
||||
spanGaps: true,
|
||||
},
|
||||
},
|
||||
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ 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 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
minStepInterval,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
FILL_MODE_MAP,
|
||||
LINE_INTERPOLATION_MAP,
|
||||
@@ -80,7 +83,14 @@ export function buildTimeSeriesConfig({
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -90,6 +100,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -102,15 +114,23 @@ 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 spanGaps = chartAppearance?.spanGaps
|
||||
? resolveSpanGaps(chartAppearance?.spanGaps)
|
||||
const resolvedSpanGaps = 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]
|
||||
|
||||
@@ -34,6 +34,8 @@ 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;
|
||||
|
||||
@@ -58,7 +58,11 @@ export enum SectionKind {
|
||||
* - `comparison` — value crosses an operator → recolor (Number)
|
||||
* - `table` — per-column comparison (Table)
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
export enum ThresholdVariant {
|
||||
LABEL = 'label',
|
||||
COMPARISON = 'comparison',
|
||||
TABLE = 'table',
|
||||
}
|
||||
|
||||
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
|
||||
export type AnyThreshold =
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,14 @@ 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([]);
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -178,11 +178,11 @@ export function mapThresholds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest per-query step interval, fed to uPlot so tick density matches the
|
||||
* Smallest per-query step interval (seconds), 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.
|
||||
*/
|
||||
function minStepInterval(
|
||||
export function minStepInterval(
|
||||
stepIntervals: Record<string, number>,
|
||||
): number | undefined {
|
||||
const values = Object.values(stepIntervals);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
}
|
||||
@@ -59,12 +59,13 @@ function Panel({
|
||||
const searchable = !!panelDefinition?.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
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 { 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 { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
@@ -92,6 +93,7 @@ function Panel({
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -21,6 +22,8 @@ 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;
|
||||
@@ -44,6 +47,7 @@ function PanelBody({
|
||||
panelId,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
@@ -52,9 +56,11 @@ function PanelBody({
|
||||
searchTerm,
|
||||
pagination,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// 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;
|
||||
// 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;
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// Not-configured panel: no runnable query, so nothing to error/load on.
|
||||
@@ -89,7 +95,9 @@ function PanelBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
// 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) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
@@ -104,6 +112,7 @@ function PanelBody({
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('PanelBody', () => {
|
||||
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the kind renderer once a runnable query is present', () => {
|
||||
const panel = panelWith([
|
||||
const runnablePanel = (): DashboardtypesPanelDTO =>
|
||||
panelWith([
|
||||
{
|
||||
spec: {
|
||||
plugin: {
|
||||
@@ -58,9 +58,62 @@ describe('PanelBody', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
render(<PanelBody {...baseProps} panel={panel} />);
|
||||
it('renders the kind renderer once a runnable query is present', () => {
|
||||
render(<PanelBody {...baseProps} panel={runnablePanel()} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -61,8 +62,8 @@ export function useCreateAlertFromPanel(): (
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: panel.spec.queries,
|
||||
panelType,
|
||||
startMs: minTime / 1e6,
|
||||
endMs: maxTime / 1e6,
|
||||
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
variables,
|
||||
});
|
||||
|
||||
|
||||
@@ -328,6 +328,12 @@ 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' }),
|
||||
@@ -377,26 +383,20 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('flags canNext on a full page and clears it on a partial page', () => {
|
||||
it('drives canNext from the response cursor, not the row count', () => {
|
||||
// Full page but no cursor → backend says these are the last rows.
|
||||
withResponse(rawResponse(25));
|
||||
const full = renderHook(() =>
|
||||
const noCursor = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(full.result.current.pagination?.canNext).toBe(true);
|
||||
expect(noCursor.result.current.pagination?.canNext).toBe(false);
|
||||
|
||||
withResponse(rawResponse(10));
|
||||
const partial = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(partial.result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('flags canNext from a nextCursor even on a partial page', () => {
|
||||
// Cursor present (even on a partial page) → more rows.
|
||||
withResponse(rawResponse(3, 'cursor-1'));
|
||||
const { result } = renderHook(() =>
|
||||
const withCursor = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination?.canNext).toBe(true);
|
||||
expect(withCursor.result.current.pagination?.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it('advances pageIndex and enables canPrev after goNext', () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,11 +42,13 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ 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;
|
||||
@@ -211,6 +213,8 @@ 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();
|
||||
@@ -232,8 +236,8 @@ export function usePanelQuery({
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
const pagination = useMemo<PanelPagination | undefined>(() => {
|
||||
if (!isPaginated) {
|
||||
return undefined;
|
||||
@@ -246,11 +250,10 @@ 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 || rowCount >= safePageSize,
|
||||
canNext: !!result?.nextCursor,
|
||||
goPrev,
|
||||
goNext,
|
||||
pageSize: safePageSize,
|
||||
@@ -271,6 +274,7 @@ export function usePanelQuery({
|
||||
data,
|
||||
isLoading: response.isLoading,
|
||||
isFetching: response.isFetching,
|
||||
isPreviousData: response.isPreviousData,
|
||||
error: response.error ?? null,
|
||||
refetch: response.refetch,
|
||||
cancelQuery,
|
||||
|
||||
@@ -37,6 +37,24 @@ 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'),
|
||||
|
||||
@@ -136,10 +136,14 @@ 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, op.value);
|
||||
addAt(parent, key, cloneDeep(op.value));
|
||||
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
|
||||
replaceAt(parent, key, op.value);
|
||||
replaceAt(parent, key, cloneDeep(op.value));
|
||||
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
|
||||
removeAt(parent, key);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from './Panels/types/panelKind';
|
||||
import type { DefaultPluginSpec } from './Panels/utils/buildDefaultPluginSpec';
|
||||
import type { SeededPluginSpec } from './Panels/utils/buildPluginSpec';
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export function panelRef(panelId: string): string {
|
||||
*/
|
||||
export function createDefaultPanel(
|
||||
pluginKind: PanelKind,
|
||||
pluginSpec: DefaultPluginSpec = {},
|
||||
pluginSpec: SeededPluginSpec = {},
|
||||
queries: DashboardtypesQueryDTO[] = [],
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
|
||||
@@ -188,9 +188,80 @@ 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' }),
|
||||
|
||||
@@ -111,6 +111,45 @@ 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', () => {
|
||||
|
||||
@@ -3,12 +3,14 @@ import type {
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5ClickHouseQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
@@ -24,6 +26,7 @@ interface QuerySpecView {
|
||||
signal?: string;
|
||||
stepInterval?: number | string;
|
||||
aggregations?: { metricName?: string }[];
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +169,48 @@ 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.
|
||||
@@ -224,6 +269,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,23 @@ 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 {
|
||||
@@ -120,7 +137,11 @@ export function toPerses(
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
const composite = mapCompositeQueryFromQuery(query, panelType);
|
||||
// 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 envelopes = toGeneratedEnvelopes(composite.queries ?? []);
|
||||
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Querybuildertypesv5AggregationBucketDTO,
|
||||
Querybuildertypesv5ExecStatsDTO,
|
||||
Querybuildertypesv5RawDataDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDTO,
|
||||
@@ -44,6 +45,13 @@ 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,
|
||||
|
||||
@@ -13,7 +13,7 @@ import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import {
|
||||
@@ -49,7 +49,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildPluginSpec(getPanelDefinition(newKind).sections),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
|
||||
@@ -26,3 +26,19 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Popover } from 'antd';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
@@ -8,18 +9,28 @@ import {
|
||||
Expand,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
cloneDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} 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 {
|
||||
@@ -42,6 +53,7 @@ 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.
|
||||
@@ -58,85 +70,159 @@ 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={
|
||||
<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();
|
||||
}}
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ 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 deleteDashboard from 'api/v1/dashboards/id/delete';
|
||||
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
deleteDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -39,12 +41,12 @@ function DeleteActionItem({
|
||||
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
|
||||
|
||||
const { mutate: runDelete } = useMutation({
|
||||
mutationFn: () => deleteDashboard({ id: dashboardId }),
|
||||
mutationFn: () => deleteDashboardV2({ id: dashboardId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(
|
||||
t('dashboard:delete_dashboard_success', { name: dashboardName }),
|
||||
);
|
||||
await invalidateListDashboardsV2(queryClient);
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
@@ -52,7 +54,7 @@ function DeleteActionItem({
|
||||
});
|
||||
|
||||
const openConfirm = useCallback((): void => {
|
||||
const { destroy } = modal.confirm({
|
||||
modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Are you sure you want to delete the
|
||||
@@ -70,14 +72,13 @@ function DeleteActionItem({
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
runDelete(undefined, { onSettled: () => destroy() });
|
||||
},
|
||||
},
|
||||
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() });
|
||||
}),
|
||||
cancelButtonProps: {
|
||||
onClick: (e): void => {
|
||||
e.stopPropagation();
|
||||
@@ -101,23 +102,25 @@ function DeleteActionItem({
|
||||
<>
|
||||
<Divider />
|
||||
<Tooltip placement="left" title={tooltip}>
|
||||
<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 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>
|
||||
</Tooltip>
|
||||
{contextHolder}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
@@ -52,6 +52,7 @@
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tagsWithActions {
|
||||
@@ -62,7 +63,15 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pinBtn {
|
||||
.lockIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -79,16 +88,16 @@
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.row:hover .pinBtn {
|
||||
.row:hover .pinButton {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
.pinButton:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.pinBtnOn {
|
||||
.pinButtonOn {
|
||||
color: var(--bg-amber-500);
|
||||
|
||||
svg {
|
||||
@@ -96,6 +105,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -182,10 +209,3 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(.titleTooltipOverlay) {
|
||||
:global(.ant-tooltip-content) :global(.ant-tooltip-inner) {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CalendarClock, LockKeyhole, Pin, PinOff } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
@@ -73,25 +74,31 @@ 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}>
|
||||
<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>
|
||||
{name.length > 50 ? (
|
||||
<TooltipSimple title={name} side="bottom" disableHoverableContent>
|
||||
{titleLink}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
titleLink
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.tagsWithActions}>
|
||||
@@ -111,17 +118,44 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
|
||||
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
{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
|
||||
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating}
|
||||
onClick={onTogglePin}
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{canAct && (
|
||||
<ActionsPopover
|
||||
|
||||
@@ -102,8 +102,8 @@ function FilterZone({
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<X size={12} />}
|
||||
onClick={onClearAll}
|
||||
|
||||
@@ -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, HdmiPort } from '@signozhq/icons';
|
||||
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
@@ -191,7 +191,7 @@ function ListHeader({
|
||||
aria-label="Columns"
|
||||
testId="configure-columns-trigger"
|
||||
>
|
||||
<HdmiPort size={14} />
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
@@ -58,6 +58,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
|
||||
@@ -20,7 +20,11 @@ import JsonEditor from './JsonEditor';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
function ImportJsonPanel(): JSX.Element {
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -59,6 +63,7 @@ function ImportJsonPanel(): 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 }),
|
||||
);
|
||||
|
||||
@@ -43,12 +43,12 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel />,
|
||||
children: <TemplatesPanel onClose={onClose} />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: 'Import JSON',
|
||||
children: <ImportJsonPanel />,
|
||||
children: <ImportJsonPanel onClose={onClose} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,136 +1,66 @@
|
||||
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 { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
|
||||
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';
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
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 selected = templates.find((t) => t.id === selectedId) ?? templates[0];
|
||||
|
||||
const handleUse = async (): Promise<void> => {
|
||||
if (!selected) {
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (creating) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
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));
|
||||
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();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error(
|
||||
(e as AxiosError).toString() || 'Failed to create from template',
|
||||
);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
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={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 className="new-dashboard-templates-modal">
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -54,5 +54,17 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
@@ -40,6 +41,8 @@ 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) : []),
|
||||
@@ -55,6 +58,11 @@ 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));
|
||||
@@ -90,7 +98,7 @@ function SearchBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
testId="dashboards-list-search-submit"
|
||||
@@ -100,7 +108,15 @@ function SearchBar({
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
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>
|
||||
</Button>
|
||||
}
|
||||
value={value}
|
||||
|
||||
@@ -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/product-features/saved-view/?utm_source=product&utm_medium=views-tab"
|
||||
href="https://signoz.io/docs/metrics-management/metrics-explorer/?utm_source=product&utm_medium=views-tab#saved-views-in-metrics-explorer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
|
||||
@@ -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/#open-the-services-section"
|
||||
href="https://signoz.io/docs/userguide/metrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -867,7 +867,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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/userguide/',
|
||||
USER_GUIDE: 'https://signoz.io/docs/introduction/',
|
||||
TRACES_DETAILS_LINK:
|
||||
'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
|
||||
'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
|
||||
METRICS_EXPLORER_EMPTY_STATE:
|
||||
'https://signoz.io/docs/userguide/send-metrics-cloud/',
|
||||
'https://signoz.io/docs/metrics-management/send-metrics/',
|
||||
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-for-distributed_signoz_index_v3',
|
||||
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing',
|
||||
QUERY_CLICKHOUSE_LOGS:
|
||||
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
|
||||
QUERY_CLICKHOUSE_METRICS:
|
||||
|
||||
@@ -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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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},
|
||||
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)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -338,19 +338,24 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
|
||||
|
||||
// dedup recomputed buckets: latest computed_at wins per (series, 60s bucket)
|
||||
dedup := sqlbuilder.NewSelectBuilder()
|
||||
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, computed_at) AS weight", weight))
|
||||
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.From(fmt.Sprintf("%s.%s", DBName, WhichReducedSamplesTableToUse(agg.Type)))
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS value", value))
|
||||
if weight != "" {
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.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.Where(
|
||||
dedup.In("metric_name", agg.MetricName),
|
||||
dedup.GTE("unix_milli", start),
|
||||
dedup.LT("unix_milli", end),
|
||||
)
|
||||
dedup.GroupBy("reduced_fingerprint", "unix_milli")
|
||||
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
dedup.GroupBy("fingerprint", "unix_milli")
|
||||
dedup.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint")
|
||||
@@ -364,13 +369,11 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
|
||||
// denominator is reduced with avg
|
||||
sb.SelectMore("avg(weight) AS per_series_weight")
|
||||
}
|
||||
sb.From(fmt.Sprintf("(%s) AS points", dedupQuery))
|
||||
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
|
||||
sb.From(fmt.Sprintf("(%s)", dedupQuery))
|
||||
sb.GroupBy("fingerprint", "ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
|
||||
initArgs := append(append([]any{}, dedupArgs...), timeSeriesCTEArgs...)
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, initArgs...)
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, dedupArgs...)
|
||||
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, true
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ 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 ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -370,36 +366,6 @@ 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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -421,142 +387,3 @@ 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
12
tests/e2e/testdata/queries.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"logs": {
|
||||
"query": ""
|
||||
},
|
||||
"metrics": {
|
||||
"metricName": "signoz_calls_total",
|
||||
"query": ""
|
||||
},
|
||||
"traces": {
|
||||
"query": ""
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
@@ -144,59 +143,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
gotoDashboardsList,
|
||||
renameDashboardViaToolbar,
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
@@ -688,38 +684,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
openDashboardSettingsDrawer,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
@@ -241,25 +240,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
createDashboardViaApi,
|
||||
DEFAULT_DASHBOARD_TITLE,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
gotoDashboardsList,
|
||||
importApmMetricsDashboardViaUI,
|
||||
@@ -17,11 +15,6 @@ 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
|
||||
@@ -429,140 +422,12 @@ 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-22 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirm';
|
||||
@@ -591,7 +456,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-23 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-cancel';
|
||||
@@ -606,7 +471,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-24 confirming delete removes the dashboard from the list', async ({
|
||||
test('TC-21 confirming delete removes the dashboard from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirmed';
|
||||
@@ -641,7 +506,7 @@ test.describe('Dashboards List Page', () => {
|
||||
|
||||
// ─── Row click navigation ────────────────────────────────────────────────
|
||||
|
||||
test('TC-25 clicking a dashboard row navigates to the detail page', async ({
|
||||
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-row-click';
|
||||
@@ -655,7 +520,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-26 sidebar Dashboards link navigates to the list page', async ({
|
||||
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/home');
|
||||
@@ -673,7 +538,7 @@ test.describe('Dashboards List Page', () => {
|
||||
|
||||
// ─── URL state and deep linking ──────────────────────────────────────────
|
||||
|
||||
test('TC-27 browser Back after navigating to a dashboard restores search state', async ({
|
||||
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-back-search';
|
||||
@@ -692,7 +557,7 @@ test.describe('Dashboards List Page', () => {
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-28 direct navigation with sort params honours them on load', async ({
|
||||
test('TC-25 direct navigation with sort params honours them on load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
|
||||
|
||||
547
tests/fixtures/clickhouse.py
vendored
547
tests/fixtures/clickhouse.py
vendored
@@ -2,6 +2,7 @@ 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
|
||||
@@ -10,37 +11,93 @@ import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
from testcontainers.clickhouse import ClickHouseContainer
|
||||
from testcontainers.core.container import Network
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLICKHOUSE_USERNAME = "signoz"
|
||||
CLICKHOUSE_PASSWORD = "password"
|
||||
|
||||
@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.
|
||||
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).
|
||||
"""
|
||||
shards = "".join(
|
||||
f"""
|
||||
<shard>
|
||||
<replica>
|
||||
<host>{host}</host>
|
||||
<port>{port}</port>
|
||||
</replica>
|
||||
</shard>"""
|
||||
for host, port in shard_hosts
|
||||
)
|
||||
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
version = request.config.getoption("--clickhouse-version")
|
||||
# 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 ""
|
||||
)
|
||||
|
||||
container = ClickHouseContainer(
|
||||
image=f"clickhouse/clickhouse-server:{version}",
|
||||
port=9000,
|
||||
username="signoz",
|
||||
password="password",
|
||||
)
|
||||
return f"""
|
||||
<remote_servers>
|
||||
<cluster>{secret_block}{shards}
|
||||
</cluster>
|
||||
</remote_servers>"""
|
||||
|
||||
cluster_config = f"""
|
||||
|
||||
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"""
|
||||
<clickhouse>
|
||||
<logger>
|
||||
<level>information</level>
|
||||
@@ -55,33 +112,23 @@ def clickhouse(
|
||||
</logger>
|
||||
|
||||
<macros>
|
||||
<shard>01</shard>
|
||||
<shard>{shard}</shard>
|
||||
<replica>01</replica>
|
||||
</macros>
|
||||
|
||||
<zookeeper>
|
||||
<node>
|
||||
<host>{zookeeper.container_configs["2181"].address}</host>
|
||||
<port>{zookeeper.container_configs["2181"].port}</port>
|
||||
<host>{zookeeper_address}</host>
|
||||
<port>{zookeeper_port}</port>
|
||||
</node>
|
||||
</zookeeper>
|
||||
|
||||
<remote_servers>
|
||||
<cluster>
|
||||
<shard>
|
||||
<replica>
|
||||
<host>127.0.0.1</host>
|
||||
<port>9000</port>
|
||||
</replica>
|
||||
</shard>
|
||||
</cluster>
|
||||
</remote_servers>
|
||||
{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>/clickhouse/task_queue/ddl</path>
|
||||
<path>{distributed_ddl_path}</path>
|
||||
<profile>default</profile>
|
||||
</distributed_ddl>
|
||||
|
||||
@@ -122,38 +169,66 @@ def clickhouse(
|
||||
</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>
|
||||
"""
|
||||
|
||||
tmp_dir = tmpfs("clickhouse")
|
||||
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)
|
||||
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(
|
||||
@@ -163,27 +238,7 @@ def clickhouse(
|
||||
container.with_network(network)
|
||||
container.start()
|
||||
|
||||
# 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()}")
|
||||
install_histogram_quantile(container)
|
||||
|
||||
connection = clickhouse_connect.get_client(
|
||||
user=container.username,
|
||||
@@ -253,7 +308,7 @@ def clickhouse(
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"clickhouse",
|
||||
cache_key,
|
||||
empty=lambda: types.TestContainerSQL(
|
||||
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
conn=None,
|
||||
@@ -265,6 +320,334 @@ def clickhouse(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
29
tests/fixtures/http.py
vendored
29
tests/fixtures/http.py
vendored
@@ -18,19 +18,22 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(name="zeus", scope="package")
|
||||
def zeus(
|
||||
ZEUS_NETWORK_ALIAS = "signoz-zeus-it"
|
||||
|
||||
|
||||
def create_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(
|
||||
@@ -42,7 +45,7 @@ def zeus(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", alias or container.get_wrapped_container().name, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
@@ -62,7 +65,7 @@ def zeus(
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"zeus",
|
||||
cache_key,
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
@@ -70,6 +73,18 @@ def 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,
|
||||
|
||||
51
tests/fixtures/metricreduction.py
vendored
Normal file
51
tests/fixtures/metricreduction.py
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
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
Reference in New Issue
Block a user