Compare commits

...

31 Commits

Author SHA1 Message Date
Srikanth Chekuri
80f50fa0a9 Merge branch 'main' into nv/dashboard-create-aggregation 2026-07-03 13:40:02 +05:30
Ashwin Bhatkal
1a84503f4b feat(dashboards): add auto-refresh to public dashboards (#11938)
* feat(dashboards): add auto-refresh to public dashboards

Add a self-contained auto-refresh interval selector to the public dashboard
header, shown alongside the time picker when the publisher enabled the time
range. On each tick it recomputes the rolling window from the selected
relative range and updates local state, so panels refetch with an advanced
window. Paused/disabled for fixed 'custom' ranges.

Kept independent of Redux global time (which the public viewer doesn't use);
reuses the shared refreshIntervalOptions.

* refactor(dashboards): dedup public auto-refresh tick, use nano constant, userEvent

Route the auto-refresh interval through handleTimeChange instead of

duplicating the GetMinMax/setSelectedTimeRange logic, replace the 1e9

magic number with NANO_SECOND_MULTIPLIER, and switch the AutoRefresh

test to userEvent.
2026-07-03 06:44:35 +00:00
Ashwin Bhatkal
9454427b47 feat(dashboard-v2): JSON drawer redaction, JSON action cleanup & breadcrumb SPA nav (#11952)
* feat(dashboard-v2): redact JSON editor to tags/spec, preserve other fields on save

The dashboard JSON drawer now exposes only the user-editable `tags` and `spec`;
server-owned/derived keys (id, orgId, name, timestamps, locked, schemaVersion,
image, …) are redacted from the view, copy, and export. On save, the edited draft
is overlaid on the current dashboard so those redacted keys are preserved.

* feat(dashboard-v2): drop redundant JSON actions, rename Edit-as-JSON to JSON

Remove the Actions-menu Data group (Export JSON / Copy as JSON) — these are
redundant with the JSON drawer's own Copy/Download. Rename the 'Edit as JSON'
toolbar button to 'JSON' (keeping the Braces icon).

* feat(dashboard-v2): client-side nav on the Dashboard breadcrumb

Intercept a plain click on the 'Dashboard' breadcrumb for instant SPA navigation
to the list page instead of a full-page reload; the href is kept so middle/
modifier-click still opens in a new tab.
2026-07-03 06:33:15 +00:00
Ashwin Bhatkal
124d76068c fix(dashboard-v2): reconcile optimistic patches from the PATCH response (#11947)
Panel delete/move flickered A -> B -> A -> B: onSettled invalidateQueries fired an
immediate GET that read pre-mutation (stale) data before settling. patchDashboardV2
already returns the updated dashboard, so reconcile the cache from that response
(onSuccess) instead of refetching. Sequence is now A -> B -> B; rollback on error
is unchanged.
2026-07-03 06:33:06 +00:00
Vinicius Lourenço
704b8bf917 chore(frontend-readme): clean the readme for frontend (#11955)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-02 21:18:44 +00:00
Vinicius Lourenço
3e6339019e fix(meter-explorer): not persisting query after page refresh (#11948)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-02 14:57:44 +00:00
Vinicius Lourenço
372d304a6f feat(meter): migrate to new uplot API & use bar stacked chart (#11902) 2026-07-02 12:23:30 +00:00
Naman Verma
dbb4eb9574 chore: validate layout size and positioning in backend (#11921)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: validate layout size and positioning in backend

* test: add integratino test for layout validation

* test: use assert instead of require for non blocking check

* test: use assert instead of require for non blocking check

* fix: move require to assert for actual test checks
2026-07-02 11:07:58 +00:00
Naman Verma
79143be510 Revert "Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation"
This reverts commit e91785bfe3, reversing
changes made to fe838a3464.
2026-06-30 21:48:14 +05:30
Naman Verma
e91785bfe3 Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation 2026-06-30 21:41:37 +05:30
Naman Verma
fe838a3464 Merge branch 'main' into nv/dashboard-create-aggregation 2026-06-30 21:40:48 +05:30
Naman Verma
ff22facdd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-30 21:40:36 +05:30
Naman Verma
875432d7ec test: add integration test 2026-06-30 21:34:17 +05:30
Naman Verma
5f1e7a3a53 chore: remove unused err type 2026-06-30 19:15:31 +05:30
Naman Verma
6a4629a418 fix: handle multi aggregations in public dashboard panels 2026-06-30 19:03:50 +05:30
Naman Verma
72a6ca6516 Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 20:07:16 +05:30
Naman Verma
bbd5cc380e Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 02:02:16 +05:30
Naman Verma
6cd9b5bbd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 15:37:15 +05:30
Naman Verma
13f6c232a1 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 11:47:24 +05:30
Naman Verma
dac1489294 Merge branch 'main' into nv/v2-public-dashboard 2026-06-17 07:29:31 +05:30
Naman Verma
1d98e9ebf6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-16 12:35:00 +05:30
Naman Verma
15e99e43ff test: add integration tests for new v2 public apis 2026-06-16 12:21:50 +05:30
Naman Verma
8c766f8c10 fix: generate api specs 2026-06-16 11:45:22 +05:30
Naman Verma
99b32f00b9 fix: add fill gaps to query 2026-06-16 02:30:59 +05:30
Naman Verma
76f8646c69 test: unit tests for GetPanelQuery 2026-06-16 02:18:01 +05:30
Naman Verma
28c00e298a chore: rename method name 2026-06-16 02:01:40 +05:30
Naman Verma
4592b12256 fix: remove fields that v1 also removes when redacting 2026-06-16 01:53:33 +05:30
Naman Verma
b990d40c5f chore: trim comments 2026-06-16 00:23:41 +05:30
Naman Verma
95a0d7c035 fix: fill fields that were in the data blob in v1 2026-06-15 23:04:21 +05:30
Naman Verma
e678728c61 fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2 2026-06-15 22:31:45 +05:30
Naman Verma
42d3e7e0e4 feat: add first draft of v2 public dashboard apis 2026-06-15 15:00:44 +05:30
33 changed files with 1830 additions and 519 deletions

View File

@@ -1,105 +1,116 @@
# Configuring Over Local
1. Docker
1. Without Docker
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../docs/readme-assets/signoz-hero-dark.png" width="700">
<source media="(prefers-color-scheme: light)" srcset="../docs/readme-assets/signoz-hero-light.png" width="700">
<img alt="SigNoz - Observability on Your Terms" src="../docs/readme-assets/signoz-hero-light.png" width="700">
</picture>
</p>
## With Docker
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
</p>
**Building image**
# SigNoz Frontend
``docker compose up`
/ This will also run
React-based web interface for [SigNoz](https://signoz.io), the open-source observability platform.
or
`docker build . -t tagname`
## Tech Stack
**Tag to remote url- Introduce versioning later on**
- **Framework:** React 18 + TypeScript
- **Build:** Vite
- **State:** React Query, Zustand, Redux Toolkit (legacy)
- **Styling:** CSS Modules, Ant Design (legacy)
- **Charts:** uPlot
- **Testing:** Jest
```
docker tag signoz/frontend:latest 7296823551/signoz:latest
## Local Development Setup
1. Run SigNoz backend locally — see [Self-Host Docs](https://signoz.io/docs/install/self-host/)
2. Configure environment:
```bash
cp example.env .env
```
Key variables in `.env`:
```bash
# Backend API endpoint (required)
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
# Enable bundle analyzer (optional)
BUNDLE_ANALYSER="true"
```
3. Install and run:
```bash
pnpm install
pnpm dev
```
## Development
```bash
pnpm dev
```
```
docker compose up
Opens [http://localhost:3301](http://localhost:3301).
## Build
```bash
pnpm build
```
## Without Docker
Follow the steps below
Output in `build/` folder.
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
## Bundle Size Analysis
1. ```pnpm install```
1. ```pnpm dev```
Set in `.env`:
```bash
BUNDLE_ANALYSER="true"
```
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
Then run build:
```bash
pnpm build
```
# Getting Started with Create React App
Opens bundle analyzer visualization automatically.
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Testing
## Available Scripts
```bash
# Unit tests
pnpm test
In the project directory, you can run:
# Type checking
pnpm tsgo --noEmit
```
### `pnpm start`
## Linting
Runs the app in the development mode.\
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
```bash
# Run all linters (oxlint + stylelint)
pnpm lint
```
The page will reload if you make edits.\
You will also see any lint errors in the console.
## Project Structure
### `pnpm test`
```
src/
├── api/ # API clients and react-query hooks
├── components/ # Shared UI components
├── container/ # Page-level containers
├── hooks/ # Custom React hooks
├── pages/ # Route pages
├── providers/ # React context providers
├── store/ # Redux store
└── types/ # TypeScript definitions
```
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Contributing
### `pnpm build`
See [CONTRIBUTING.md](../CONTRIBUTING.md) in the root repo.
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `pnpm eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `pnpm build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
Questions? Join our [Slack community](https://signoz.io/slack).

View File

@@ -0,0 +1,8 @@
.emptyMeterSearch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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));
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);

View 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;
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,13 @@
.autoRefresh {
display: flex;
align-items: center;
gap: 6px;
}
.icon {
color: var(--l2-foreground);
}
.select {
min-width: 96px;
}

View File

@@ -0,0 +1,41 @@
import { RefreshCw } from '@signozhq/icons';
import { SelectSimple } from '@signozhq/ui/select';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import styles from './AutoRefresh.module.scss';
const REFRESH_ITEMS = refreshIntervalOptions.map((option) => ({
value: option.key,
label: option.key === 'off' ? 'Off' : option.label,
}));
interface AutoRefreshProps {
value: string;
disabled?: boolean;
onChange: (value: string) => void;
}
// Interval selector for the public dashboard. Self-contained (no Redux global
// time); the container advances its own time window on each tick.
function AutoRefresh({
value,
disabled = false,
onChange,
}: AutoRefreshProps): JSX.Element {
return (
<div className={styles.autoRefresh}>
<RefreshCw size={14} className={styles.icon} />
<SelectSimple
className={styles.select}
testId="public-dashboard-auto-refresh"
items={REFRESH_ITEMS}
value={value}
disabled={disabled}
withPortal={false}
onChange={(next): void => onChange(next as string)}
/>
</div>
);
}
export default AutoRefresh;

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react';
import RGL, { WidthProvider } from 'react-grid-layout';
import { useInterval } from 'react-use';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { Card, CardContainer } from 'container/GridCardLayout/styles';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
@@ -14,12 +16,14 @@ import {
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { SuccessResponseV2 } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import AutoRefresh from './AutoRefresh';
import Panel from './Panel';
import './PublicDashboardContainer.styles.scss';
@@ -121,14 +125,34 @@ function PublicDashboardContainer({
const { maxTime, minTime } = GetMinMax(interval);
setSelectedTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
});
}
setSelectedTimeRangeLabel(interval as string);
};
const [refreshIntervalKey, setRefreshIntervalKey] = useState<string>('off');
// Auto-refresh only makes sense for a rolling relative range, not a fixed
// custom window — pause it (and disable the control) when 'custom' is picked.
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
const refreshIntervalMs = useMemo(
() =>
refreshIntervalOptions.find((option) => option.key === refreshIntervalKey)
?.value || 0,
[refreshIntervalKey],
);
// Re-run the existing time-change handler with the current relative range so
// the rolling window advances — no need to duplicate the GetMinMax logic.
useInterval(
() => handleTimeChange(selectedTimeRangeLabel as Time),
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
);
return (
<div className="public-dashboard-container">
<div className="public-dashboard-header">
@@ -148,6 +172,11 @@ function PublicDashboardContainer({
{isTimeRangeEnabled && (
<div className="public-dashboard-header-right">
<AutoRefresh
value={refreshIntervalKey}
disabled={isAutoRefreshPaused}
onChange={setRefreshIntervalKey}
/>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh={false}

View File

@@ -0,0 +1,23 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from 'tests/test-utils';
import AutoRefresh from '../AutoRefresh';
describe('Public dashboard AutoRefresh', () => {
it('renders the interval selector', () => {
render(<AutoRefresh value="off" onChange={jest.fn()} />);
expect(
screen.getByTestId('public-dashboard-auto-refresh'),
).toBeInTheDocument();
});
it('lets the viewer pick a refresh interval', async () => {
const onChange = jest.fn();
render(<AutoRefresh value="off" onChange={onChange} />);
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
await userEvent.click(screen.getByText('30 seconds'));
expect(onChange).toHaveBeenCalledWith('30s');
});
});

View File

@@ -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;

View File

@@ -1,14 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Braces,
ClipboardCopy,
Configure,
Copy,
FileJson,
Fullscreen,
Grid3X3,
LockKeyhole,
@@ -64,7 +60,6 @@ function DashboardActions({
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -74,7 +69,6 @@ function DashboardActions({
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
@@ -90,32 +84,6 @@ function DashboardActions({
[addSection],
);
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = useCallback(
(): string => JSON.stringify(dashboard, null, 2),
[dashboard],
);
const exportJSON = useCallback((): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [dashboardDataJSON, title]);
const handleClone = useCallback(async (): Promise<void> => {
if (!dashboard.id) {
return;
@@ -176,21 +144,6 @@ function DashboardActions({
onClick: handle.enter,
});
const dataGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
icon: <FileJson size={14} />,
onClick: exportJSON,
},
{
key: 'copy',
label: 'Copy as JSON',
icon: <ClipboardCopy size={14} />,
onClick: (): void => setCopy(dashboardDataJSON()),
},
];
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
@@ -208,7 +161,6 @@ function DashboardActions({
label: 'Dashboard',
children: dashboardGroup,
},
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
];
if (layoutGroup.length > 0) {
items.push({
@@ -240,9 +192,6 @@ function DashboardActions({
handleClone,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
]);
return (
@@ -287,7 +236,7 @@ function DashboardActions({
onClick={(): void => setIsJsonEditorOpen(true)}
size="md"
>
Edit as JSON
JSON
</Button>
{!isDashboardLocked && (
<Button

View File

@@ -44,7 +44,9 @@ const dashboard = {
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const serialized = JSON.stringify(dashboard, null, 2);
// The editor only exposes `tags` and `spec`; every other key is redacted.
const redacted = { tags: dashboard.tags, spec: dashboard.spec };
const serialized = JSON.stringify(redacted, null, 2);
describe('useJsonEditor', () => {
beforeEach(() => {
@@ -63,6 +65,19 @@ describe('useJsonEditor', () => {
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
});
it('redacts server-owned keys from the editable draft', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
const parsed = JSON.parse(result.current.draft);
expect(Object.keys(parsed).sort()).toStrictEqual(['spec', 'tags']);
expect(parsed.id).toBeUndefined();
expect(parsed.name).toBeUndefined();
expect(parsed.schemaVersion).toBeUndefined();
expect(parsed.image).toBeUndefined();
});
it('flags invalid JSON with a line number and marks the draft dirty', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
@@ -119,14 +134,23 @@ describe('useJsonEditor', () => {
expect(mockUpdate).not.toHaveBeenCalled();
});
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
it('apply() PUTs the edited spec/tags while preserving redacted keys, toasts, refetches and calls onApplied', async () => {
const onApplied = jest.fn();
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied }),
);
const next = { ...dashboard, name: 'Renamed' };
act(() => result.current.setDraft(JSON.stringify(next)));
// Edit only what the redacted view exposes (spec/tags).
const editedSpec = {
...dashboard.spec,
display: { name: 'Renamed' },
};
const editedTags = [{ key: 'env', value: 'staging' }];
act(() =>
result.current.setDraft(
JSON.stringify({ tags: editedTags, spec: editedSpec }),
),
);
await act(async () => {
await result.current.apply();
});
@@ -135,10 +159,13 @@ describe('useJsonEditor', () => {
expect(mockUpdate).toHaveBeenCalledWith(
{ id: 'dash-1' },
expect.objectContaining({
name: 'Renamed',
// preserved from the original dashboard (redacted from the editor)
name: 'My dashboard',
schemaVersion: 'v6',
spec: next.spec,
tags: next.tags,
image: 'icon.png',
// edited via the draft
spec: editedSpec,
tags: editedTags,
}),
);
expect(mockToastSuccess).toHaveBeenCalled();

View File

@@ -33,8 +33,20 @@ interface Result {
apply: () => Promise<void>;
}
/**
* The editable, user-facing view: only `tags` and `spec`. Everything else
* (id, orgId, name, timestamps, locked, schemaVersion, image, …) is redacted so it
* can't be seen, copied, exported or edited; those keys are preserved on save (see `apply`).
*/
const redact = (
dashboard: DashboardtypesGettableDashboardV2DTO,
): Pick<DashboardtypesGettableDashboardV2DTO, 'tags' | 'spec'> => ({
tags: dashboard.tags,
spec: dashboard.spec,
});
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
JSON.stringify(dashboard, null, 2);
JSON.stringify(redact(dashboard), null, 2);
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
function errorLineFromMessage(
@@ -115,8 +127,13 @@ export function useJsonEditor({
}
try {
setIsSaving(true);
const parsed = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
// The draft only carries name/tags/spec; overlay it on the current dashboard
// so the redacted fields (schemaVersion, image, …) are preserved on save.
const edited = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2(
{ id: dashboardId },
dashboardToUpdatable({ ...dashboard, ...edited }),
);
toast.success('Dashboard updated');
refetch();
onApplied();
@@ -126,6 +143,7 @@ export function useJsonEditor({
setIsSaving(false);
}
}, [
dashboard,
dashboardId,
validity.valid,
isDirty,

View File

@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { MouseEvent, useMemo } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import {
Breadcrumb,
@@ -21,6 +23,8 @@ function DashboardPageBreadcrumbs({
title,
image,
}: DashboardPageBreadcrumbsProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dashboardPageLink = useMemo(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
@@ -31,11 +35,25 @@ function DashboardPageBreadcrumbs({
: ROUTES.ALL_DASHBOARD;
}, []);
// Keep the href for middle/modifier-click (open in new tab) but intercept a plain
// click for instant client-side navigation instead of a full-page reload.
const onDashboardLinkClick = (event: MouseEvent<HTMLAnchorElement>): void => {
if (isModifierKeyPressed(event)) {
return;
}
event.preventDefault();
safeNavigate(dashboardPageLink);
};
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
<BreadcrumbLink
icon={<LayoutGrid size={14} />}
href={dashboardPageLink}
onClick={onDashboardLinkClick}
>
Dashboard
</BreadcrumbLink>
</BreadcrumbItem>

View File

@@ -100,8 +100,23 @@ describe('useOptimisticPatch', () => {
expect(queryClient.setQueryData).not.toHaveBeenCalled();
});
it('onSettled invalidates the dashboard query to reconcile', () => {
captured.options.onSettled();
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(QUERY_KEY);
it('onSuccess reconciles the cache from the PATCH response without a refetch', () => {
const response = {
data: { spec: { display: { name: 'B' } } },
status: 'success',
};
captured.options.onSuccess(response);
expect(queryClient.setQueryData).toHaveBeenCalledWith(
QUERY_KEY,
expect.any(Function),
);
// The updater merges the server data into the existing envelope.
const updater = queryClient.setQueryData.mock.calls[0][1];
expect(
updater({ data: { spec: { display: { name: 'A' } } }, foo: 1 }),
).toStrictEqual({ data: { spec: { display: { name: 'B' } } }, foo: 1 });
// No follow-up GET (that could read stale data and flicker the UI back).
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
});

View File

@@ -26,7 +26,9 @@ export interface UseOptimisticPatch {
/**
* Central optimistic mutation for V2 dashboard spec edits: writes the ops to the
* cached dashboard immediately, rolls back on error, reconciles on settle.
* cached dashboard immediately, rolls back on error, and reconciles from the PATCH
* response (which returns the updated dashboard) rather than a follow-up GET — an
* immediate refetch can read stale, pre-mutation data and flicker the UI back.
* `dashboardId` defaults to the edit-context store; the panel editor passes its own.
*/
export function useOptimisticPatch(
@@ -60,8 +62,10 @@ export function useOptimisticPatch(
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries(queryKey);
onSuccess: (response) => {
queryClient.setQueryData<GetDashboardV2200>(queryKey, (prev) =>
prev ? { ...prev, data: response.data } : (response as GetDashboardV2200),
);
},
});

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -406,27 +405,34 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
isRawRequest := dashboard.getQueryRequestTypeFromPanelType(widgetData.PanelTypes) == querybuildertypesv5.RequestTypeRaw
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
// build aggregations the same way the frontend does before hitting the query
// range API; raw requests carry no aggregations.
if isRawRequest {
delete(query, "aggregations")
} else {
query["aggregations"] = querybuildertypesv5.CreateAggregation(query, widgetData.PanelTypes)
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -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

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)},

View File

@@ -0,0 +1,214 @@
package querybuildertypesv5
import (
"regexp"
"strings"
)
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// aggregationExprRegexp matches a function-style aggregation like `count()` or
// `sum(field)` with an optional `as <alias>`, as the frontend's parseAggregations does.
var aggregationExprRegexp = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?`)
// CreateAggregation builds the v5 aggregations for a stored builder query, mirroring
// createAggregation in the frontend's prepareQueryRangePayloadV5.ts. Metrics yield a
// single structured aggregation; logs/traces split their comma-separated expression into
// one aggregation per call, defaulting to count() when nothing parses.
func CreateAggregation(queryData map[string]any, panelType string) []any {
if queryData == nil {
return []any{}
}
if dataSource, _ := queryData["dataSource"].(string); dataSource == "metrics" {
var first map[string]any
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
first, _ = aggs[0].(map[string]any)
}
attribute, _ := queryData["aggregateAttribute"].(map[string]any)
metric := map[string]any{}
setFirstNonEmpty(metric, "metricName", first["metricName"], attribute["key"])
setFirstNonEmpty(metric, "temporality", first["temporality"], attribute["temporality"])
setFirstNonEmpty(metric, "timeAggregation", first["timeAggregation"], queryData["timeAggregation"])
setFirstNonEmpty(metric, "spaceAggregation", first["spaceAggregation"], queryData["spaceAggregation"])
if panelType == "table" || panelType == "pie" || panelType == "value" {
setFirstNonEmpty(metric, "reduceTo", first["reduceTo"], queryData["reduceTo"])
}
return []any{metric}
}
aggs, ok := queryData["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return []any{map[string]any{"expression": "count()"}}
}
result := []any{}
for _, agg := range aggs {
aggMap, _ := agg.(map[string]any)
expression, _ := aggMap["expression"].(string)
alias, _ := aggMap["alias"].(string)
parsed := parseAggregations(expression, alias)
if len(parsed) == 0 {
result = append(result, map[string]any{"expression": "count()"})
continue
}
result = append(result, parsed...)
}
return result
}
// parseAggregations extracts each function-style call from a (possibly comma-separated)
// aggregation expression, attaching the inline `as` alias or the fallback alias.
func parseAggregations(expression, fallbackAlias string) []any {
result := []any{}
for _, match := range aggregationExprRegexp.FindAllStringSubmatch(expression, -1) {
agg := map[string]any{"expression": match[1]}
if alias := match[2]; alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
} else if fallbackAlias != "" {
agg["alias"] = fallbackAlias
}
result = append(result, agg)
}
return result
}
// setFirstNonEmpty sets key to the first value that is neither nil nor "", mirroring the
// JS `a || b` fallback the frontend uses for the metric aggregation fields.
func setFirstNonEmpty(target map[string]any, key string, values ...any) {
for _, v := range values {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
target[key] = v
return
}
}

View File

@@ -0,0 +1,176 @@
package querybuildertypesv5
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateAggregation(t *testing.T) {
testCases := []struct {
description string
queryData map[string]any
panelType string
expectedOutput []any
}{
{
description: "nil query data yields no aggregations",
queryData: nil,
expectedOutput: []any{},
},
{
description: "single logs expression is left untouched",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "comma separated trace expressions are split into one object each",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "count(), sum(price)"}}},
expectedOutput: []any{
map[string]any{"expression": "count()"},
map[string]any{"expression": "sum(price)"},
},
},
{
description: "inline alias is preserved and unquoted",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as 'total', sum(price) as revenue"}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "total"},
map[string]any{"expression": "sum(price)", "alias": "revenue"},
},
},
{
description: "space separated expressions split with an unquoted alias on the first only",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as cnt avg(code.lineno) "}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "cnt"},
map[string]any{"expression": "avg(code.lineno)"},
},
},
{
description: "fallback alias is applied when expression has no inline alias",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()", "alias": "hits"}}},
expectedOutput: []any{map[string]any{"expression": "count()", "alias": "hits"}},
},
{
description: "commas inside function arguments do not split the expression",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "countIf(day > 10, status)"}}},
expectedOutput: []any{map[string]any{"expression": "countIf(day > 10, status)"}},
},
{
description: "unparseable expression falls back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "not-an-aggregation"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "empty aggregations fall back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "missing aggregations fall back to count()",
queryData: map[string]any{"dataSource": "traces"},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "metric aggregation is built from the first aggregation",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric omits temporality when empty, matching the frontend `|| undefined`",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"temporality": "",
"reduceTo": "avg",
"aggregations": []any{map[string]any{
"metricName": "cpu_usage",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "cpu_usage",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
{
description: "metric includes reduceTo for table/pie/value panels",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
{
description: "metric drops reduceTo for other panels even when query data has it",
panelType: "graph",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric falls back to legacy aggregateAttribute and top-level fields",
queryData: map[string]any{
"dataSource": "metrics",
"aggregateAttribute": map[string]any{"key": "legacy_metric", "temporality": "cumulative"},
"timeAggregation": "avg",
"spaceAggregation": "max",
},
expectedOutput: []any{map[string]any{
"metricName": "legacy_metric",
"temporality": "cumulative",
"timeAggregation": "avg",
"spaceAggregation": "max",
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.expectedOutput, CreateAggregation(testCase.queryData, testCase.panelType))
})
}
}

View File

@@ -1,3 +1,4 @@
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
@@ -8,6 +9,7 @@ from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logs import Logs
from fixtures.metrics import Metrics
from fixtures.types import Operation, SigNoz, TestContainerDocker
@@ -205,6 +207,147 @@ def test_public_dashboard_widget_query_range(
assert resp.status_code == HTTPStatus.BAD_REQUEST
def test_public_dashboard_widget_query_range_multi_aggregation(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
):
"""
A logs/traces widget stores several aggregations as one comma-separated expression
(e.g. "count(), sum(latency_ms)"). The public widget query path must split it into
one aggregation per call, mirroring the frontend, before handing it to the querier.
If the split does not happen the querier receives a single malformed aggregation and
the request fails - so a successful response with two aggregations proves the split.
"""
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Unique per-run service so the widget query only sees this run's logs.
service_name = f"multiagg-public-{uuid.uuid4()}"
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(minutes=5),
resources={"service.name": service_name},
attributes={"latency_ms": 100},
body="multi-agg log 1",
),
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": service_name},
attributes={"latency_ms": 200},
body="multi-agg log 2",
),
Logs(
timestamp=now - timedelta(minutes=1),
resources={"service.name": service_name},
attributes={"latency_ms": 300},
body="multi-agg log 3",
),
]
)
dashboard_req = {
"title": "Multi Aggregation Public Widget",
"description": "Comma-separated aggregations must be split on the public query path",
"version": "v5",
"widgets": [
{
"id": "b2c0a1d4-9f3e-4c2a-8a7b-1e2f3a4b5c6d",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregations": [{"expression": "count(), sum(latency_ms)"}],
"dataSource": "logs",
"disabled": False,
"expression": "A",
"filter": {"expression": f"service.name = '{service_name}'"},
"functions": [],
"groupBy": [],
"having": {"expression": ""},
"legend": "",
"limit": 10,
"orderBy": [],
"queryName": "A",
"source": "",
"stepInterval": 60,
}
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"id": "c3d1b2e5-0a4f-4d3b-9b8c-2f3a4b5c6d7e",
"promql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"queryType": "builder",
},
}
],
}
create_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json=dashboard_req,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert create_response.status_code == HTTPStatus.CREATED
dashboard_id = create_response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
json={"timeRangeEnabled": False, "defaultTimeRange": "30m"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
public_path = response.json()["data"]["publicPath"]
public_dashboard_id = public_path.split("/public/dashboard/")[-1]
resp = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/public/dashboards/{public_dashboard_id}/widgets/0/query_range"),
timeout=5,
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "success"
# The single "count(), sum(latency_ms)" expression must have been split into two
# separate aggregations on the way to the querier.
results = body["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 2
# With no group-by each aggregation produces a single series.
for aggregation in aggregations:
assert len(aggregation["series"]) == 1
assert len(aggregation["series"][0]["values"]) > 0
# Each aggregation is computed independently over the three logs: count() totals 3,
# sum(latency_ms) totals 100 + 200 + 300 = 600. Summing each aggregation's points is
# robust to step bucketing and to the order the aggregations come back in.
aggregation_totals = sorted(
sum(point["value"] for series in aggregation["series"] for point in series["values"])
for aggregation in aggregations
)
assert aggregation_totals == [3, 600]
def test_anonymous_role_has_public_dashboard_permission(
request: pytest.FixtureRequest,
signoz: SigNoz,

View File

@@ -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",
[