mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 21:00:38 +01:00
Compare commits
3 Commits
fix/panel-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6339019e | ||
|
|
372d304a6f | ||
|
|
dbb4eb9574 |
@@ -0,0 +1,8 @@
|
||||
.emptyMeterSearch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EmptyMeterSearch.module.scss';
|
||||
|
||||
interface EmptyMeterSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMeterSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMeterSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyMeterSearch}>
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,34 +73,6 @@
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-meter-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.time-series-view-panel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
padding: 8px !important;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.time-series-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, calc(50% - 8px)), 1fr)
|
||||
);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +85,6 @@
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.meter-time-series-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.builder-units-filter {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
.builder-units-filter-label {
|
||||
margin-bottom: 0px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@@ -35,7 +35,6 @@ function Explorer(): JSX.Element {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -67,15 +66,6 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryData(0, {
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
source: 'meter',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.loadingMeter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
height: 240px;
|
||||
padding: var(--spacing-12) 0;
|
||||
}
|
||||
|
||||
.loadingMeterContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.loadingGif {
|
||||
height: 72px;
|
||||
margin-left: calc(-1 * var(--spacing-12));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import styles from './MeterLoading.module.scss';
|
||||
|
||||
export default function MeterLoading(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingMeter}>
|
||||
<div className={styles.loadingMeterContent}>
|
||||
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
|
||||
<Typography>Retrieving your {DataSource.METRICS}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.meterTimeSeriesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-5);
|
||||
width: 100%;
|
||||
|
||||
:global(.builder-units-filter) {
|
||||
padding: 0 var(--spacing-4);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:global(.builder-units-filter-label) {
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSeriesContainer {
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
padding-right: 16px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.timeSeriesViewPanel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isAxiosError } from 'axios';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { buildMeterChartConfig } from './configBuilder';
|
||||
import EmptyMeterSearch from './EmptyMeterSearch';
|
||||
import MeterLoading from './MeterLoading';
|
||||
import styles from './TimeSeries.module.scss';
|
||||
import { useTimeSeriesQueries } from './useTimeSeriesQueries';
|
||||
import { useTimeSeriesTimeManagement } from './useTimeSeriesTimeManagement';
|
||||
|
||||
const WIDGET_ID = 'meter-explorer-bar-chart';
|
||||
|
||||
interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
@@ -32,144 +33,124 @@ function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
const { minTimeScale, maxTimeScale, onDragSelect } =
|
||||
useTimeSeriesTimeManagement({
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
});
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() => [stagedQuery || initialQueryMeterWithType],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
graphType: PANEL_TYPES.BAR,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
status = error.getHttpStatusCode();
|
||||
} else if (isAxiosError(error)) {
|
||||
status = error.response?.status;
|
||||
}
|
||||
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() =>
|
||||
data.map((datapoint) =>
|
||||
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
|
||||
),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
const { responseData, isLoading, isError } = useTimeSeriesQueries({
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
onFetchingStateChange,
|
||||
});
|
||||
|
||||
const hasMetricSelected = useMemo(
|
||||
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
const chartsData = useMemo(() => {
|
||||
return responseData.map((response, index) => {
|
||||
const apiResponse = response?.payload;
|
||||
|
||||
const config = buildMeterChartConfig({
|
||||
id: `${WIDGET_ID}-${index}`,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit: yAxisUnit || 'short',
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const chartData = apiResponse ? prepareChartData(apiResponse) : [];
|
||||
|
||||
return {
|
||||
config,
|
||||
chartData,
|
||||
hasData: chartData.length > 0 && chartData[0]?.length > 0,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
responseData,
|
||||
currentQuery,
|
||||
yAxisUnit,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
]);
|
||||
|
||||
const hasAnyData = chartsData.some((chart) => chart.hasData);
|
||||
|
||||
return (
|
||||
<div className="meter-time-series-container">
|
||||
<div className={styles.meterTimeSeriesContainer}>
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
<div className={styles.timeSeriesContainer} ref={graphRef}>
|
||||
{!hasMetricSelected && <EmptyMeterSearch />}
|
||||
{isCancelled && hasMetricSelected && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{isLoading && hasMetricSelected && !isCancelled && <MeterLoading />}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
!hasAnyData && (
|
||||
<EmptyMeterSearch hasQueryResult={responseData[0] !== undefined} />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 &&
|
||||
chartsData.map(
|
||||
(chart, index) =>
|
||||
chart.hasData && (
|
||||
<div
|
||||
className={styles.timeSeriesViewPanel}
|
||||
// oxlint-disable-next-line react/no-array-index-key -- query responses have no stable ID
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
}}
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
117
frontend/src/container/MeterExplorer/Explorer/configBuilder.ts
Normal file
117
frontend/src/container/MeterExplorer/Explorer/configBuilder.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export interface MeterChartConfigProps {
|
||||
id: string;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
yAxisUnit: string;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
export function buildMeterChartConfig({
|
||||
id,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: MeterChartConfigProps): UPlotConfigBuilder {
|
||||
const stepIntervals = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
) as Record<string, number>;
|
||||
const minStepInterval = Object.keys(stepIntervals).length
|
||||
? Math.min(...Object.values(stepIntervals))
|
||||
: undefined;
|
||||
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = getLegend(series, currentQuery, baseLabelName);
|
||||
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseTimeSeriesQueriesProps {
|
||||
stagedQuery: Query | null;
|
||||
currentQuery: Query;
|
||||
globalSelectedTime: Time | CustomTimeType;
|
||||
maxTime: number;
|
||||
minTime: number;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseTimeSeriesQueriesResult {
|
||||
responseData: (SuccessResponse<MetricRangePayloadProps> | undefined)[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useTimeSeriesQueries({
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
onFetchingStateChange,
|
||||
}: UseTimeSeriesQueriesProps): UseTimeSeriesQueriesResult {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() => [stagedQuery || initialQueryMeterWithType],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
graphType: PANEL_TYPES.BAR,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
status = error.getHttpStatusCode();
|
||||
} else if (isAxiosError(error)) {
|
||||
status = error.response?.status;
|
||||
}
|
||||
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const responseData = useMemo(() => {
|
||||
const data = queries.map(({ data }) => data) ?? [];
|
||||
return data.map((datapoint) =>
|
||||
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
|
||||
);
|
||||
}, [queries, isValidToConvertToMs]);
|
||||
|
||||
const isLoading = queries.some((q) => q.isLoading);
|
||||
const isError = queries.some((q) => q.isError);
|
||||
|
||||
return {
|
||||
responseData,
|
||||
isLoading,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
interface UseTimeSeriesTimeManagementProps {
|
||||
globalSelectedTime: Time | CustomTimeType;
|
||||
maxTime: number;
|
||||
minTime: number;
|
||||
}
|
||||
|
||||
interface UseTimeSeriesTimeManagementResult {
|
||||
minTimeScale: number | undefined;
|
||||
maxTimeScale: number | undefined;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
export function useTimeSeriesTimeManagement({
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
}: UseTimeSeriesTimeManagementProps): UseTimeSeriesTimeManagementResult {
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange();
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [maxTime, minTime, globalSelectedTime]);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
},
|
||||
[dispatch, location.pathname, urlQuery],
|
||||
);
|
||||
|
||||
const handleBackNavigation = useCallback((): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const startTime = searchParams.get(QueryParams.startTime);
|
||||
const endTime = searchParams.get(QueryParams.endTime);
|
||||
const relativeTime = searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
) as CustomTimeType;
|
||||
|
||||
if (relativeTime) {
|
||||
dispatch(UpdateTimeInterval(relativeTime));
|
||||
} else if (startTime && endTime && startTime !== endTime) {
|
||||
dispatch(
|
||||
UpdateTimeInterval('custom', [
|
||||
parseInt(getTimeString(startTime), 10),
|
||||
parseInt(getTimeString(endTime), 10),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', handleBackNavigation);
|
||||
return (): void => {
|
||||
window.removeEventListener('popstate', handleBackNavigation);
|
||||
};
|
||||
}, [handleBackNavigation]);
|
||||
|
||||
return {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { stackSeries } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -57,6 +58,7 @@ function TimeSeriesView({
|
||||
dataSource,
|
||||
setWarning,
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -65,11 +67,23 @@ function TimeSeriesView({
|
||||
const location = useLocation();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const chartData = useMemo(
|
||||
const rawChartData = useMemo(
|
||||
() => getUPlotChartData(data?.payload),
|
||||
[data?.payload],
|
||||
);
|
||||
|
||||
const { chartData, stackedBands } = useMemo(() => {
|
||||
if (!stackBarChart || !rawChartData || rawChartData.length < 2) {
|
||||
return { chartData: rawChartData, stackedBands: null };
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false;
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
rawChartData as uPlot.AlignedData,
|
||||
noSeriesHidden,
|
||||
);
|
||||
return { chartData: stacked, stackedBands: bands };
|
||||
}, [rawChartData, stackBarChart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning?.(data?.warning);
|
||||
@@ -189,7 +203,7 @@ function TimeSeriesView({
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const chartOptions = getUPlotChartOptions({
|
||||
const baseChartOptions = getUPlotChartOptions({
|
||||
id: 'time-series-explorer',
|
||||
onDragSelect,
|
||||
yAxisUnit: yAxisUnit || '',
|
||||
@@ -222,6 +236,14 @@ function TimeSeriesView({
|
||||
},
|
||||
});
|
||||
|
||||
const chartOptions = useMemo(
|
||||
() =>
|
||||
stackedBands
|
||||
? { ...baseChartOptions, bands: stackedBands }
|
||||
: baseChartOptions,
|
||||
[baseChartOptions, stackedBands],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="time-series-view">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
@@ -282,6 +304,7 @@ interface TimeSeriesViewProps {
|
||||
dataSource: DataSource;
|
||||
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
panelType?: PANEL_TYPES;
|
||||
stackBarChart?: boolean;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
@@ -290,6 +313,7 @@ TimeSeriesView.defaultProps = {
|
||||
error: undefined,
|
||||
setWarning: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart: false,
|
||||
};
|
||||
|
||||
export default TimeSeriesView;
|
||||
|
||||
@@ -138,14 +138,33 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
const maxLayoutsPerDashboard = 500
|
||||
|
||||
// validateLayouts validates the dashboard's layouts: bounded section count,
|
||||
// per-item geometry, resolvable panel references, and no panel placed twice.
|
||||
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
|
||||
// runs here so its errors can name the layout by index.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
if len(d.Layouts) > maxLayoutsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
|
||||
}
|
||||
|
||||
// Could enforce this but skipping for now: panels in no grid item (orphans)
|
||||
// are allowed.
|
||||
|
||||
// The frontend keys each grid item by its panel id, so placing one panel in
|
||||
// two grid items collides; reject duplicate references dashboard-wide. Maps
|
||||
// each referenced panel key to the path of the item that first placed it.
|
||||
referencedPanels := make(map[string]string, len(d.Panels))
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
return err
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
@@ -158,6 +177,10 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
if firstPath, dup := referencedPanels[key]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
|
||||
}
|
||||
referencedPanels[key] = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -299,19 +299,22 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
|
||||
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
|
||||
// p2 fills the right half of row 0, so p1 can only move to a fresh row
|
||||
// without tripping overlap validation.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// The first item used to live at x=0, now lives at x=6.
|
||||
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
// The first item used to live at y=0, now lives at y=6.
|
||||
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
assert.Contains(t, raw, `"width":3`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
@@ -321,11 +324,12 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
}]`).Apply(base)
|
||||
// Appending needs a not-yet-placed panel, so add one in the same patch;
|
||||
// re-placing p1 or p2 would be a duplicate reference.
|
||||
out, err := decode(t, `[
|
||||
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
|
||||
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
@@ -25,19 +26,19 @@ func TestValidateBigExample(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
_, err = unmarshalDashboard(data)
|
||||
require.NoError(t, err, "expected valid dashboard")
|
||||
assert.NoError(t, err, "expected valid dashboard")
|
||||
}
|
||||
|
||||
func TestValidateDashboardWithSections(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_with_sections.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
_, err = unmarshalDashboard(data)
|
||||
require.NoError(t, err, "expected valid dashboard")
|
||||
assert.NoError(t, err, "expected valid dashboard")
|
||||
}
|
||||
|
||||
func TestInvalidateNotAJSON(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte("not json"))
|
||||
require.Error(t, err, "expected error for invalid JSON")
|
||||
assert.Error(t, err, "expected error for invalid JSON")
|
||||
}
|
||||
|
||||
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
|
||||
@@ -60,11 +61,11 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
|
||||
require.Contains(t, err.Error(), "unknown panel plugin kind",
|
||||
assert.Contains(t, err.Error(), "unknown panel plugin kind",
|
||||
"outer wrap should not smother the inner UnmarshalJSON message")
|
||||
require.Contains(t, err.Error(), `"NonExistentPanel"`,
|
||||
assert.Contains(t, err.Error(), `"NonExistentPanel"`,
|
||||
"the offending value should still appear in the error")
|
||||
require.Contains(t, err.Error(), "allowed values:",
|
||||
assert.Contains(t, err.Error(), "allowed values:",
|
||||
"the allowed-values hint should still appear in the error")
|
||||
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput),
|
||||
@@ -77,7 +78,7 @@ func TestValidateEmptySpec(t *testing.T) {
|
||||
// no variables no panels
|
||||
data := []byte(`{}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "expected valid")
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestValidateOnlyVariables(t *testing.T) {
|
||||
@@ -109,7 +110,7 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "expected valid")
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
@@ -136,7 +137,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for duplicate variable name")
|
||||
require.Contains(t, err.Error(), `duplicate variable name "env"`)
|
||||
assert.Contains(t, err.Error(), `duplicate variable name "env"`)
|
||||
}
|
||||
|
||||
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
@@ -163,19 +164,19 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.Error(t, err, "expected error for invalid variable name %q", name)
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
assert.Contains(t, err.Error(), "is not a correct name")
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.NoError(t, err, "expected valid variable name %q", name)
|
||||
assert.NoError(t, err, "expected valid variable name %q", name)
|
||||
})
|
||||
}
|
||||
t.Run("digits only", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName("123"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot contain only digits")
|
||||
assert.Contains(t, err.Error(), "cannot contain only digits")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,7 +200,7 @@ func TestInvalidatePanelKey(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for invalid panel key")
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
assert.Contains(t, err.Error(), "is not a correct name")
|
||||
}
|
||||
|
||||
func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
@@ -225,30 +226,30 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
assert.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
assert.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
assert.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("valid sort is accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unknown sort is rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown sort")
|
||||
assert.Contains(t, err.Error(), "unknown sort")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,7 +276,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for empty variable name")
|
||||
require.Contains(t, err.Error(), "name cannot be empty")
|
||||
assert.Contains(t, err.Error(), "name cannot be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -414,7 +415,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(tt.data))
|
||||
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
|
||||
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -435,7 +436,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for invalid panel plugin kind")
|
||||
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
|
||||
assert.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
|
||||
}
|
||||
|
||||
func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
@@ -488,11 +489,11 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(tt.data)
|
||||
if tt.wantContain == "" {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -570,7 +571,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(tt.data))
|
||||
require.Error(t, err, "expected error for unknown field")
|
||||
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -649,7 +650,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(tt.data))
|
||||
require.Error(t, err, "expected validation error")
|
||||
if tt.wantContain != "" {
|
||||
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -874,7 +875,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(tt.data))
|
||||
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
|
||||
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -907,7 +908,7 @@ func TestThresholdLabelOptional(t *testing.T) {
|
||||
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
|
||||
assert.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -924,7 +925,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected panel-without-queries to be rejected")
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
@@ -942,7 +943,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected panel with explicit empty queries array to be rejected")
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
}
|
||||
|
||||
// Rendering multiple data sources in a single panel is supported via
|
||||
@@ -965,7 +966,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected panel with two top-level queries to be rejected")
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
}
|
||||
|
||||
func TestValidateRequiredFields(t *testing.T) {
|
||||
@@ -1053,7 +1054,7 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(tt.data))
|
||||
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
|
||||
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1081,14 +1082,14 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
require.IsType(t, &TimeSeriesPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
|
||||
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
|
||||
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
|
||||
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
assert.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
|
||||
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
|
||||
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
|
||||
// and verify the output contains the default values.
|
||||
@@ -1131,8 +1132,8 @@ func TestNumberPanelDefaults(t *testing.T) {
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
|
||||
require.Len(t, spec.Thresholds, 1, "expected 1 threshold")
|
||||
require.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
|
||||
require.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
|
||||
assert.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
|
||||
assert.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
|
||||
|
||||
// Marshal back and verify defaults in JSON output.
|
||||
output, err := json.Marshal(d)
|
||||
@@ -1163,7 +1164,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err, "map → JSON (read-back shape)")
|
||||
|
||||
var roundtripped DashboardSpec
|
||||
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
|
||||
assert.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
|
||||
}
|
||||
|
||||
// TestStorageRoundTrip simulates the future DB store/load cycle:
|
||||
@@ -1329,9 +1330,9 @@ func TestGenerateDashboardName(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.scenario, func(t *testing.T) {
|
||||
got := generateDashboardName(tt.input)
|
||||
require.NotEmpty(t, got)
|
||||
require.LessOrEqual(t, len(got), 63)
|
||||
require.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
|
||||
assert.NotEmpty(t, got)
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
|
||||
|
||||
if tt.wantPrefix == "" {
|
||||
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
|
||||
@@ -1346,8 +1347,8 @@ func TestGenerateDashboardName(t *testing.T) {
|
||||
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
|
||||
input := strings.Repeat("a", 100)
|
||||
got := generateDashboardName(input)
|
||||
require.LessOrEqual(t, len(got), 63)
|
||||
require.Empty(t, validation.IsDNS1123Label(got))
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got))
|
||||
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
|
||||
})
|
||||
|
||||
@@ -1435,10 +1436,130 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(tc.data)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridGeometry(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
items []dashboard.GridItem
|
||||
expectErrContain string
|
||||
}{
|
||||
{
|
||||
scenario: "valid side-by-side items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "valid full-width item",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "stacked items do not overlap",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "zero width",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
|
||||
expectErrContain: "width must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "zero height",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
|
||||
expectErrContain: "height must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "negative x",
|
||||
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "negative y",
|
||||
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
|
||||
expectErrContain: "y must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "width wider than grid",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
|
||||
expectErrContain: "width (13) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x at grid width",
|
||||
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
|
||||
expectErrContain: "x (12) must be less than grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x plus width overflows grid",
|
||||
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x (8) + width (6) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "overlapping items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
|
||||
expectErrContain: "items[0] and items[1] overlap",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
|
||||
if test.expectErrContain == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), test.expectErrContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridItemLimit(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "maximum is")
|
||||
}
|
||||
|
||||
// Both panel refs are valid, so this errors only if geometry validation runs on
|
||||
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
|
||||
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
|
||||
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
|
||||
]}}]
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "overlap")
|
||||
}
|
||||
|
||||
// The frontend keys each grid item by its panel id, so the same panel placed by
|
||||
// two grid items crashes the section; the backend rejects it dashboard-wide. The
|
||||
// two items are side by side so they clear the overlap check first.
|
||||
func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
]}}]
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already placed")
|
||||
// Both offending grid items are named.
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
@@ -322,6 +322,55 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
gridColumnCount = 12
|
||||
maxItemsPerGridLayout = 100
|
||||
)
|
||||
|
||||
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
|
||||
// position, and intra-section overlap), which Perses does not. It reads only the
|
||||
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
|
||||
// solely to name the layout in error paths.
|
||||
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
|
||||
if len(spec.Items) > maxItemsPerGridLayout {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
|
||||
}
|
||||
for i, item := range spec.Items {
|
||||
// The width/x bounds keep x+width small enough not to overflow.
|
||||
switch {
|
||||
case item.Width < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
|
||||
case item.Height < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
|
||||
case item.X < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
|
||||
case item.Y < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
|
||||
case item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
|
||||
case item.X >= gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
|
||||
case item.X+item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
|
||||
}
|
||||
// Could cap y/height but skipping for now: the grid grows vertically
|
||||
// without limit (frontend autoSize), so "too big" has no natural bound.
|
||||
}
|
||||
// Two items overlap iff their rectangles intersect on both axes.
|
||||
overlap := func(a, b dashboard.GridItem) bool {
|
||||
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
|
||||
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
|
||||
}
|
||||
for i := 0; i < len(spec.Items); i++ {
|
||||
for j := i + 1; j < len(spec.Items); j++ {
|
||||
if overlap(spec.Items[i], spec.Items[j]) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Layout) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
|
||||
|
||||
@@ -173,6 +173,125 @@ def test_create_rejects_too_many_tags(
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_invalid_grid_layout(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def panel(name: str) -> dict:
|
||||
return {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": name},
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Two grid items reference valid, distinct panels but share cells, so the
|
||||
# overlap is the only violation.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-overlap",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Overlap"},
|
||||
"panels": {"p1": panel("P1"), "p2": panel("P2")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "overlap" in response.json()["error"]["message"]
|
||||
|
||||
# One panel placed by two grid items (side by side, so they clear the overlap
|
||||
# check first). The frontend keys grid items by panel id, so this is rejected.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-multiref",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Multiref"},
|
||||
"panels": {"p1": panel("P1")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "already placed" in response.json()["error"]["message"]
|
||||
|
||||
# More grid items than allowed. The item-count check runs before the
|
||||
# panel-ref check, so content-less items suffice here.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-too-many-items",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Too Many"},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "maximum" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user