Compare commits

..

2 Commits

Author SHA1 Message Date
vikrantgupta25
baa5da538f chore(sql): remove the redundant flag from integration tests 2026-07-09 18:32:38 +05:30
vikrantgupta25
abe3a0aa88 chore(sql): enable txlock immediate by default 2026-07-09 18:26:04 +05:30
62 changed files with 520 additions and 2215 deletions

View File

@@ -45,15 +45,9 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -76,15 +76,9 @@ jobs:
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -34,8 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR ?= /tmp/signoz-integration-buildx-cache
##############################################################
# directories
@@ -212,7 +210,7 @@ py-lint: ## Run ruff check across the shared tests project
.PHONY: py-test-setup
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
@cd tests && SIGNOZ_INTEGRATION_BUILD_CACHE_DIR=$(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
.PHONY: py-test-teardown
py-test-teardown: ## Tear down the shared SigNoz backend
@@ -231,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
@echo ">> python cache cleaned"
.PHONY: py-docker-clean
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
@echo ">> removing SigNoz integration test image"
@docker image rm -f signoz:integration 2>/dev/null || true
@echo ">> removing local integration buildx cache directories"
@rm -rf $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR)-next
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
.PHONY: py-test-clean
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
@$(MAKE) py-test-teardown || true
@$(MAKE) py-clean
@$(MAKE) py-docker-clean
##############################################################
# generate commands

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM golang:1.25-bookworm
ARG OS="linux"
@@ -23,8 +21,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
RUN chmod 755 /root /root/signoz

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM node:22-bookworm AS build
WORKDIR /opt/
@@ -32,8 +30,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -129,7 +129,7 @@ sqlstore:
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: deferred
transaction_mode: immediate
##################### APIServer #####################
apiserver:

View File

@@ -49,23 +49,6 @@ describe('ContextLinksSection utils', () => {
{ key: 'q', value: '{{x}}' },
]);
});
it('treats undecodable percent sequences as literal text instead of throwing', () => {
expect(getUrlParams('/logs?search=95%')).toStrictEqual([
{ key: 'search', value: '95%' },
]);
expect(getUrlParams('/logs?95%=value')).toStrictEqual([
{ key: '95%', value: 'value' },
]);
});
it('decodes a valid escape once even when the result has a stray percent', () => {
// %2525 double-decodes to '%'; 95%25 decodes once to '95%' and stops there
expect(getUrlParams('/logs?a=95%25&b=%2525')).toStrictEqual([
{ key: 'a', value: '95%' },
{ key: 'b', value: '%' },
]);
});
});
describe('updateUrlWithParams', () => {

View File

@@ -29,20 +29,16 @@ export function insertVariableAtCursor(
);
}
// Users can type raw `%` into the URL field (e.g. `?q=95%`), which is not valid
// percent-encoding — treat undecodable input as literal text instead of throwing.
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
// Values may be double-encoded on the wire, so decode twice; a second decode is a
// no-op once nothing is left to unescape, leaving single-encoded values intact.
// Values may be double-encoded on the wire; decode a second time only when it changes
// the string, so already-single-encoded values are left intact.
function decodeForDisplay(value: string): string {
return safeDecodeURIComponent(safeDecodeURIComponent(value));
const decoded = decodeURIComponent(value);
try {
const doubleDecoded = decodeURIComponent(decoded);
return doubleDecoded !== decoded ? doubleDecoded : decoded;
} catch {
return decoded;
}
}
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
@@ -57,7 +53,7 @@ export function getUrlParams(url: string): UrlParam[] {
const [key, value] = pair.split('=');
if (key) {
params.push({
key: safeDecodeURIComponent(key),
key: decodeURIComponent(key),
value: decodeForDisplay(value || ''),
});
}

View File

@@ -11,8 +11,6 @@ interface ColumnUnitsProps {
columns: TableColumnOption[];
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
value: Record<string, string>;
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
metricUnit?: string;
onChange: (next: Record<string, string>) => void;
}
@@ -25,7 +23,6 @@ interface ColumnUnitsProps {
function ColumnUnits({
columns,
value,
metricUnit,
onChange,
}: ColumnUnitsProps): JSX.Element {
if (columns.length === 0) {
@@ -56,7 +53,6 @@ function ColumnUnits({
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
value={value[column.key]}
initialValue={metricUnit}
containerClassName={styles.columnUnitSelector}
onChange={(unit): void => setUnit(column.key, unit)}
/>

View File

@@ -81,7 +81,6 @@ function FormattingSection({
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
metricUnit={metricUnit}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>

View File

@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
@@ -100,33 +100,4 @@ describe('FormattingSection', () => {
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
it('warns when a column unit mismatches the metric unit', () => {
// metric sent in seconds, but the column is set to bytes.
render(
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the column unit matches the metric unit', () => {
render(
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -26,7 +26,12 @@
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
// Pin the query-type tabs + Run Query button to the top of the
// `.container` scroll area while the query body scrolls underneath.
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
position: sticky;
top: 0px;
z-index: 1100;
padding-top: 12px;
background-color: var(--l1-background);
@@ -35,13 +40,6 @@
}
}
}
// Opt-in pin to the top of the scroll area; the View modal opts out (shares a scroll area with its own header).
.stickyNav :global(.ant-tabs-nav) {
position: sticky;
top: 0px;
z-index: 1100;
}
.queryTypeTab {
display: flex;
align-items: center;

View File

@@ -7,7 +7,6 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import PromQLIcon from 'assets/Dashboard/PromQl';
@@ -46,8 +45,6 @@ interface PanelEditorQueryBuilderProps {
onCancelQuery: () => void;
/** Optional content pinned below the builder (e.g. the List columns editor). */
footer?: ReactNode;
/** Pin the tabs + Run Query row to the top of the scroll area. Off in the View modal, which shares a scroll area with its own header. */
stickyHeader?: boolean;
}
/**
@@ -62,7 +59,6 @@ function PanelEditorQueryBuilder({
onStageRunQuery,
onCancelQuery,
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
@@ -160,9 +156,7 @@ function PanelEditorQueryBuilder({
<div className={styles.scrollArea}>
<Tabs
type="card"
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
className={styles.tabsContainer}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={

View File

@@ -5,7 +5,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
/**
* Characterization test for the editor's composition: which derived values and
@@ -20,7 +19,7 @@ const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue('panel-1');
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
@@ -62,8 +61,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('../hooks/useSeedMetricUnit', () => ({
useSeedMetricUnit: (): unknown => ({
jest.mock('../hooks/useMetricYAxisUnit', () => ({
useMetricYAxisUnit: (): unknown => ({
metricUnit: undefined,
isLoading: false,
}),
@@ -105,17 +104,12 @@ jest.mock('@signozhq/ui/sonner', () => ({
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
default: (props: { onSave: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<>
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
<button type="button" data-testid="editor-close" onClick={props.onClose}>
close
</button>
</>
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
);
},
}));
@@ -202,10 +196,7 @@ function setup(
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => {
jest.clearAllMocks();
useScrollIntoViewStore.setState({ scrollTargetId: null });
});
beforeEach(() => jest.clearAllMocks());
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
@@ -308,32 +299,6 @@ describe('PanelEditorContainer composition', () => {
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() =>
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1'),
);
});
it('marks an existing panel to be revealed when the editor is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1');
});
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
it('offers Switch to View Mode for an existing panel', () => {
setup(makePanel('signoz/TimeSeriesPanel'));

View File

@@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -247,13 +247,11 @@ describe('usePanelEditorQuerySync', () => {
});
describe('datasource switch', () => {
const withSource = (id: string, ...dataSources: string[]): Query =>
const withSource = (id: string, dataSource: string): Query =>
({
id,
queryType: 'builder',
builder: {
queryData: dataSources.map((dataSource) => ({ dataSource })),
},
builder: { queryData: [{ dataSource }] },
}) as unknown as Query;
it('commits the active query when a query datasource changes', () => {
@@ -288,58 +286,6 @@ describe('usePanelEditorQuerySync', () => {
expect(setSpec).not.toHaveBeenCalled();
});
it('does not commit when a query is added (the fresh query must not auto-run)', () => {
const state = builderState({ currentQuery: withSource('a', 'metrics') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics', 'metrics');
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
it('commits when a query is removed', () => {
const state = builderState({
currentQuery: withSource('a', 'metrics', 'logs'),
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('commits a datasource switch on a query added after mount', () => {
const state = builderState({ currentQuery: withSource('a', 'metrics') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics', 'metrics');
rerender();
state.currentQuery = withSource('c', 'metrics', 'logs');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
});
describe('query dirty + save', () => {

View File

@@ -24,8 +24,6 @@ jest.mock('api/generated/services/dashboard', () => ({
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
describe('usePanelEditorSave', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -46,7 +44,7 @@ describe('usePanelEditorSave', () => {
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
const savedPanelId = await result.current.save(spec);
await result.current.save(spec);
expect(mockPatchAsync).toHaveBeenCalledWith([
{
@@ -55,25 +53,6 @@ describe('usePanelEditorSave', () => {
value: spec,
},
]);
// Editing resolves with the panel's own id.
expect(savedPanelId).toBe('panel-9');
});
it('mints and resolves with a fresh id when creating a new panel', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
);
const spec = {
display: { name: 'New panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
const savedPanelId = await result.current.save(spec);
expect(savedPanelId).toBe('minted-panel-id');
expect(mockPatchAsync).toHaveBeenCalled();
});
it('surfaces the patch in-flight state as isSaving', () => {

View File

@@ -1,265 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import type { TableColumnOption } from '../useTableColumns';
import { useSeedMetricUnit } from '../useSeedMetricUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
return {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: formatting ? { formatting } : {},
},
} as unknown as DashboardtypesPanelSpecDTO;
}
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
?.unit;
}
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
.formatting?.columnUnits;
}
const COLUMNS: TableColumnOption[] = [
{ key: 'A', label: 'A' },
{ key: 'B', label: 'B' },
];
const NO_COLUMNS: TableColumnOption[] = [];
describe('useSeedMetricUnit', () => {
beforeEach(() => jest.clearAllMocks());
describe('panel-wide unit (controls.unit)', () => {
it('seeds formatting.unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: false,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec({ unit: 'bytes' }),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO }) =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: props.spec,
onChangeSpec,
}),
{ initialProps: { spec: makeSpec() } },
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
// Metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ spec: makeSpec({ unit: 'bytes' }) });
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
});
});
describe('per-column units (controls.columnUnits)', () => {
it('seeds every value column with the metric unit once columns resolve', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { columns: TableColumnOption[] }) =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: props.columns,
spec: makeSpec(),
onChangeSpec,
}),
{ initialProps: { columns: NO_COLUMNS } },
);
// Waits for results to resolve the columns.
expect(onChangeSpec).not.toHaveBeenCalled();
rerender({ columns: COLUMNS });
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
A: 'bytes',
B: 'bytes',
});
});
it('never writes formatting.unit for a Table', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true, decimals: true },
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
});
it('only fills columns without a unit yet, keeping the user-set one', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec({ columnUnits: { A: 'ms' } }),
onChangeSpec,
}),
);
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
A: 'ms',
B: 'bytes',
});
});
it('does not write when every column already has a unit', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('seeds once and does not re-run after the metric unit changes', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
const { rerender } = renderHook(
(props: { metric: string }) => {
mockMetricUnit(props.metric);
return useSeedMetricUnit({
isNewPanel: true,
formattingControls: { columnUnits: true },
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
});
},
{ initialProps: { metric: 'bytes' } },
);
expect(onChangeSpec).toHaveBeenCalledTimes(1);
rerender({ metric: 'ms' });
expect(onChangeSpec).toHaveBeenCalledTimes(1);
});
});
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
mockMetricUnit('bytes');
const onChangeSpec = jest.fn();
renderHook(() =>
useSeedMetricUnit({
isNewPanel: true,
formattingControls: undefined,
columns: COLUMNS,
spec: makeSpec(),
onChangeSpec,
}),
);
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useSeedMetricUnit({
isNewPanel: false,
formattingControls: { unit: true },
columns: NO_COLUMNS,
spec: makeSpec(),
onChangeSpec: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -111,27 +111,17 @@ export function usePanelEditorQuerySync({
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is reset to.
const dataSources = useMemo(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
[currentQuery.builder],
);
const dataSourceSignature = dataSources.join(',');
const prevDataSourcesRef = useRef(dataSources);
const didMountRef = useRef(false);
useEffect(() => {
const prev = prevDataSourcesRef.current;
prevDataSourcesRef.current = dataSources;
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
// An added query is still empty — don't auto-run it; it commits on Run Query.
const isQueryAdded =
dataSources.length > prev.length &&
prev.every((source, index) => source === dataSources[index]);
if (isQueryAdded) {
return;
}
commitRef.current(queryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);

View File

@@ -23,8 +23,7 @@ interface UsePanelEditorSaveArgs {
}
interface UsePanelEditorSaveApi {
/** Resolves with the saved panel's id (freshly minted when creating). */
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
@@ -45,20 +44,17 @@ export function usePanelEditorSave({
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
let ops: DashboardtypesJSONPatchOperationDTO[];
// The id a new panel is persisted under (surfaced so the caller can reveal it).
let savedPanelId = panelId;
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
savedPanelId = uuid();
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
panelId: savedPanelId,
panelId: uuid(),
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
} else {
@@ -73,7 +69,6 @@ export function usePanelEditorSave({
// Optimistic cache write + settle refetch (replaces the manual invalidate).
await patchAsync(ops);
return savedPanelId;
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
);

View File

@@ -1,95 +0,0 @@
import { useEffect, useRef } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import type {
SectionControls,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
import type { TableColumnOption } from './useTableColumns';
type FormattingControls = SectionControls[SectionKind.Formatting];
interface UseSeedMetricUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
isNewPanel: boolean;
/**
* The current kind's Formatting controls — the single source of truth for which
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
* A kind with neither (Histogram/List) seeds nothing.
*/
formattingControls: FormattingControls | undefined;
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
columns: TableColumnOption[];
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UseSeedMetricUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
* mismatch warning.
*/
export function useSeedMetricUnit({
isNewPanel,
formattingControls,
columns,
spec,
onChangeSpec,
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
const seedsUnit = isNewPanel && !!formattingControls?.unit;
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
useEffect(() => {
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
return;
}
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedsUnit, metricUnit]);
// Per-column units (Table): seed once, only for columns without a unit yet, so it
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
const seededColumnsRef = useRef(false);
useEffect(() => {
if (
!seedsColumnUnits ||
seededColumnsRef.current ||
!metricUnit ||
columns.length === 0
) {
return;
}
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
const unset = columns.filter(
(column) => columnUnits[column.key] === undefined,
);
seededColumnsRef.current = true;
if (unset.length === 0) {
return;
}
const nextColumnUnits = { ...columnUnits };
unset.forEach((column) => {
nextColumnUnits[column.key] = metricUnit;
});
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
// Seed once columns first resolve with a unit; not on later spec edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedsColumnUnits, metricUnit, columns]);
return { metricUnit, isLoading };
}

View File

@@ -8,29 +8,25 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
@@ -128,20 +124,32 @@ function PanelEditorContainer({
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
@@ -180,17 +188,6 @@ function PanelEditorContainer({
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
@@ -206,33 +203,19 @@ function PanelEditorContainer({
query: currentQuery,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
await save(buildSaveSpec(draft.spec));
toast.success('Panel saved');
onSaved();
} catch {
toast.error('Failed to save panel');
}
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
@@ -244,7 +227,7 @@ function PanelEditorContainer({
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onCloseEditor}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"

View File

@@ -1,37 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { readFormatting, writeFormatting } from '../formattingSpec';
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
return {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: formatting ? { formatting } : {},
},
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('formattingSpec', () => {
it('reads the formatting slice (undefined when absent)', () => {
expect(readFormatting(makeSpec())).toBeUndefined();
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
unit: 'bytes',
});
});
it('merges the patch into the formatting slice, preserving other fields', () => {
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
unit: 'bytes',
});
expect(readFormatting(next)).toStrictEqual({
decimalPrecision: '2',
unit: 'bytes',
});
});
it('does not mutate the input spec', () => {
const spec = makeSpec({ unit: 'ms' });
writeFormatting(spec, { unit: 'bytes' });
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
});
});

View File

@@ -1,27 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
// to the shared `formatting` slice via a single localized cast at the boundary, so
// callers read/write it without repeating the spread.
export function readFormatting(
spec: DashboardtypesPanelSpecDTO,
): PanelFormattingSlice | undefined {
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
}
/** Merges a partial formatting patch into the panel's `formatting` slice. */
export function writeFormatting(
spec: DashboardtypesPanelSpecDTO,
patch: Partial<PanelFormattingSlice>,
): DashboardtypesPanelSpecDTO {
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
return {
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
},
} as DashboardtypesPanelSpecDTO;
}

View File

@@ -28,36 +28,14 @@ const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
function oldSpecWith(
pluginSpec: unknown,
queries: unknown[] = [],
): DashboardtypesPanelSpecDTO {
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
queries,
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function builderQueryNamed(name: string): unknown {
return {
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: { name, aggregations: [{ expression: 'count()' }] },
},
},
};
}
function compositeQueryWith(envelopes: unknown[]): unknown {
return {
spec: {
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
},
};
}
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
@@ -69,15 +47,16 @@ describe('buildPluginSpec', () => {
expect(buildPluginSpec([])).toStrictEqual({});
});
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
{ kind: SectionKind.ContextLinks },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
const result = buildPluginSpec([
{ kind: SectionKind.Legend, controls: { colors: true } },
]);
@@ -133,108 +112,6 @@ describe('buildPluginSpec', () => {
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('carries old timePreference / stacking / fillSpans the target declares', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: {
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
},
},
];
const oldSpec = oldSpecWith({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
stackedBarChart: true,
fillSpans: true,
},
});
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
stackedBarChart: true,
fillSpans: true,
});
});
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
];
const oldSpec = oldSpecWith({
visualization: { stackedBarChart: true },
});
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
timePreference: DashboardtypesTimePreferenceDTO.global_time,
});
});
it('carries old legend position but never customColors', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
];
const oldSpec = oldSpecWith({
legend: {
position: DashboardtypesLegendPositionDTO.right,
customColors: { 'series-a': '#F1575F' },
},
});
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
position: DashboardtypesLegendPositionDTO.right,
});
});
});
describe('axes seed (carry, gated by controls)', () => {
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
];
const oldSpec = oldSpecWith({
axes: { softMin: 0, softMax: 100, isLogScale: true },
});
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
softMin: 0,
softMax: 100,
isLogScale: true,
});
});
it('carries only the fields the target controls declare', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { logScale: true } },
];
const oldSpec = oldSpecWith({
axes: { softMin: 0, softMax: 100, isLogScale: true },
});
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
isLogScale: true,
});
});
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
expect(
buildPluginSpec(sections, {
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
}),
).toStrictEqual({});
});
});
describe('chartAppearance seed', () => {
@@ -260,30 +137,6 @@ describe('buildPluginSpec', () => {
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('carries old values over the defaults, gated by the declared controls', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
},
];
const oldSpec = oldSpecWith({
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.dashed,
fillMode: DashboardtypesFillModeDTO.gradient,
showPoints: false,
},
});
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
{
lineStyle: DashboardtypesLineStyleDTO.dashed,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
showPoints: false,
},
);
});
});
describe('formatting seed (carry, gated by controls)', () => {
@@ -301,7 +154,7 @@ describe('buildPluginSpec', () => {
});
});
it('drops the panel-wide unit when no column keys are derivable (TimeSeries → Table, no queries)', () => {
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
const sections: SectionConfig[] = [
{
@@ -318,56 +171,6 @@ describe('buildPluginSpec', () => {
});
});
it('fans the panel-wide unit out to every value column (TimeSeries → Table)', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
const oldSpec = oldSpecWith(
{ formatting: { unit: 'ms', decimalPrecision: 2 } },
[
compositeQueryWith([
{
type: 'builder_query',
spec: {
name: 'A',
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
},
},
{
type: 'builder_query',
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
},
]),
],
);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 2,
columnUnits: {
'A.count()': 'ms',
'A.sum(bytes)': 'ms',
B: 'ms',
},
});
});
it('never seeds a panel-wide unit from per-column units (Table → TimeSeries)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
];
const oldSpec = oldSpecWith(
{ formatting: { columnUnits: { A: 'ms', B: 'ns' }, decimalPrecision: 2 } },
[builderQueryNamed('A')],
);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 2,
});
});
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
@@ -422,14 +225,12 @@ describe('buildPluginSpec', () => {
function switchThresholds(
variant: ThresholdVariant | undefined,
thresholds: unknown[],
queries: unknown[] = [],
): unknown {
const sections: SectionConfig[] = [
{ kind: SectionKind.Thresholds, controls: { variant } },
];
return buildPluginSpec(sections, {
oldSpec: oldSpecWith({ thresholds }, queries),
}).thresholds;
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
.thresholds;
}
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
@@ -457,55 +258,27 @@ describe('buildPluginSpec', () => {
]);
});
it('preserves operator/format and keys onto the first query column when remapping comparison → table', () => {
it('preserves existing operator/format when remapping comparison → table', () => {
expect(
switchThresholds(
ThresholdVariant.TABLE,
[
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
[builderQueryNamed('A')],
),
switchThresholds(ThresholdVariant.TABLE, [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
]),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: 'A',
columnName: '',
},
]);
});
it('keeps an existing columnName instead of the derived default', () => {
expect(
switchThresholds(
ThresholdVariant.TABLE,
[{ value: 80, color: '#F1575F', columnName: 'p99' }],
[builderQueryNamed('A')],
),
).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
]);
});
it('drops table thresholds when no column can be derived (empty columnName fails the save)', () => {
expect(
switchThresholds(ThresholdVariant.TABLE, [{ value: 80, color: '#F1575F' }]),
).toBeUndefined();
});
it('drops table-only operator/format/columnName when remapping table → label', () => {
expect(
switchThresholds(ThresholdVariant.LABEL, [

View File

@@ -1,79 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { getTableColumnKeys } from '../getTableColumnKeys';
function bareBuilderQuery(spec: unknown): DashboardtypesQueryDTO {
return {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
} as unknown as DashboardtypesQueryDTO;
}
function compositeQuery(envelopes: unknown[]): DashboardtypesQueryDTO {
return {
spec: {
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
},
} as unknown as DashboardtypesQueryDTO;
}
describe('getTableColumnKeys', () => {
it('keys single-aggregation queries by name and multi-aggregation ones per expression (matches getColId)', () => {
const queries = [
compositeQuery([
{
type: 'builder_query',
spec: {
name: 'A',
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
},
},
{
type: 'builder_query',
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
},
]),
];
expect(getTableColumnKeys(queries)).toStrictEqual([
'A.count()',
'A.sum(bytes)',
'B',
]);
});
it('skips disabled queries', () => {
const queries = [
compositeQuery([
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
{ type: 'builder_query', spec: { name: 'B' } },
]),
];
expect(getTableColumnKeys(queries)).toStrictEqual(['B']);
});
it('keys non-builder envelopes by name but skips clickhouse_sql (columns keyed by unknown SQL alias)', () => {
const queries = [
compositeQuery([
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A * 2' } },
{ type: 'clickhouse_sql', spec: { name: 'CH1', query: 'SELECT 1' } },
]),
];
expect(getTableColumnKeys(queries)).toStrictEqual(['F1']);
});
it('reads a bare builder-query envelope', () => {
expect(getTableColumnKeys([bareBuilderQuery({ name: 'A' })])).toStrictEqual([
'A',
]);
});
it('returns no keys when there is no enabled named query', () => {
expect(getTableColumnKeys([])).toStrictEqual([]);
expect(
getTableColumnKeys([
compositeQuery([
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
]),
]),
).toStrictEqual([]);
});
});

View File

@@ -13,7 +13,6 @@ import {
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
import {
type AnyThreshold,
type ControlledSectionKind,
type PanelFormattingSlice,
type SectionConfig,
type SectionControls,
@@ -21,18 +20,13 @@ import {
type SectionSpecMap,
ThresholdVariant,
} from '../types/sections';
import { getTableColumnKeys } from './getTableColumnKeys';
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
export interface SeededPluginSpec {
visualization?: SectionSpecMap[SectionKind.Visualization];
axes?: SectionSpecMap[SectionKind.Axes];
legend?: SectionSpecMap[SectionKind.Legend];
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
formatting?: Pick<
PanelFormattingSlice,
'unit' | 'decimalPrecision' | 'columnUnits'
>;
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: SectionSpecMap[SectionKind.Columns];
thresholds?: AnyThreshold[];
}
@@ -43,11 +37,6 @@ export interface SeedContext {
signal?: TelemetrytypesSignalDTO;
}
/** `SeedContext` plus `oldSpec.plugin.spec` (typed `unknown`) resolved once, so seeds read it without re-casting. */
interface ResolvedSeedContext extends SeedContext {
oldPluginSpec?: SeededPluginSpec;
}
interface AnyThresholdFields {
color: string;
value: number;
@@ -62,7 +51,6 @@ interface AnyThresholdFields {
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
defaultColumnName?: string,
): AnyThreshold {
const core = {
color: source.color,
@@ -81,7 +69,7 @@ function toThresholdVariant(
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName || defaultColumnName || '',
columnName: source.columnName ?? '',
};
}
return {
@@ -91,166 +79,97 @@ function toThresholdVariant(
}
/**
* How each section derives its plugin-spec slice on create/switch — the single place a section
* declares this. Sections absent from `SECTION_SEEDS` seed nothing. Mapped over `SectionKind` so
* every `seed` receives its own kind's `controls` (atomic kinds get `undefined`), no per-seed cast.
* A `seed` returns an empty object/array (never `undefined`) when it has nothing to carry;
* `buildPluginSpec` drops the empties so the omit decision lives in one place.
* How one section derives its plugin-spec slice on create/switch — the single place a section
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
*/
type SectionSeeds = {
[K in SectionKind]?: {
specKey: keyof SeededPluginSpec;
seed: (
controls: K extends ControlledSectionKind ? SectionControls[K] : undefined,
ctx: ResolvedSeedContext,
) => object;
};
};
function isEmptySlice(value: object): boolean {
return Array.isArray(value)
? value.length === 0
: Object.keys(value).length === 0;
interface SectionSeed {
specKey: keyof SeededPluginSpec;
seed: (controls: unknown, ctx: SeedContext) => unknown;
}
const SECTION_SEEDS: SectionSeeds = {
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (
controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.Visualization] => {
const old = oldPluginSpec?.visualization;
return {
...(controls.timePreference && {
timePreference:
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
}),
...(controls.stacking &&
old?.stackedBarChart !== undefined && {
stackedBarChart: old.stackedBarChart,
}),
...(controls.fillSpans &&
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
};
},
},
[SectionKind.Axes]: {
specKey: 'axes',
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Axes] => {
const old = oldPluginSpec?.axes;
if (!old) {
return {};
}
return {
...(controls.minMax &&
typeof old.softMin === 'number' && { softMin: old.softMin }),
...(controls.minMax &&
typeof old.softMax === 'number' && { softMax: old.softMax }),
...(controls.logScale &&
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
};
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
const c = controls as SectionControls[SectionKind.Visualization];
return c.timePreference
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
: undefined;
},
},
[SectionKind.Legend]: {
specKey: 'legend',
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Legend] => {
const old = oldPluginSpec?.legend;
// customColors is keyed by series label, which the new kind may not reproduce.
return controls.position
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
: {};
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
const c = controls as SectionControls[SectionKind.Legend];
return c.position
? { position: DashboardtypesLegendPositionDTO.bottom }
: undefined;
},
},
[SectionKind.ChartAppearance]: {
specKey: 'chartAppearance',
seed: (
controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.ChartAppearance] => {
// One guard on the optional old slice, then read fields with carried-or-default values.
const {
lineStyle = DashboardtypesLineStyleDTO.solid,
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
fillMode = DashboardtypesFillModeDTO.none,
showPoints,
spanGaps,
} = oldPluginSpec?.chartAppearance ?? {};
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
const c = controls as SectionControls[SectionKind.ChartAppearance];
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (controls.lineStyle) {
appearance.lineStyle = lineStyle;
if (c.lineStyle) {
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (controls.lineInterpolation) {
appearance.lineInterpolation = lineInterpolation;
if (c.lineInterpolation) {
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
}
if (controls.fillMode) {
appearance.fillMode = fillMode;
if (c.fillMode) {
appearance.fillMode = DashboardtypesFillModeDTO.none;
}
if (controls.showPoints && showPoints !== undefined) {
appearance.showPoints = showPoints;
}
if (controls.spanGaps && spanGaps !== undefined) {
appearance.spanGaps = spanGaps;
}
return appearance;
return Object.keys(appearance).length > 0 ? appearance : undefined;
},
},
[SectionKind.Formatting]: {
specKey: 'formatting',
seed: (
controls,
{ oldSpec, oldPluginSpec },
): NonNullable<SeededPluginSpec['formatting']> => {
const old = oldPluginSpec?.formatting;
{ oldSpec },
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
const c = controls as SectionControls[SectionKind.Formatting];
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
?.formatting;
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
// else the save API rejects the spec.
const carried: NonNullable<SeededPluginSpec['formatting']> = {
...(controls.unit && old?.unit !== undefined && { unit: old.unit }),
...(controls.decimals &&
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
...(c.decimals &&
old?.decimalPrecision !== undefined && {
decimalPrecision: old.decimalPrecision,
}),
};
// A panel-wide unit fans out to every value column when the target keys units
// per column (→ Table). One-way: `columnUnits` never seed a panel-wide `unit`.
const unit = old?.unit;
if (controls.columnUnits && unit) {
const keys = getTableColumnKeys(oldSpec?.queries ?? []);
if (keys.length > 0) {
carried.columnUnits = Object.fromEntries(keys.map((key) => [key, unit]));
}
}
return carried;
return Object.keys(carried).length > 0 ? carried : undefined;
},
},
[SectionKind.Columns]: {
specKey: 'selectFields',
seed: (_controls, { signal }): SectionSpecMap[SectionKind.Columns] =>
signal ? defaultColumnsForSignal(signal) : [],
seed: (
_controls,
{ signal },
): SectionSpecMap[SectionKind.Columns] | undefined => {
if (!signal) {
return undefined;
}
const columns = defaultColumnsForSignal(signal);
return columns.length > 0 ? columns : undefined;
},
},
[SectionKind.Thresholds]: {
specKey: 'thresholds',
seed: (controls, { oldSpec, oldPluginSpec }): AnyThreshold[] => {
const variant = controls.variant ?? ThresholdVariant.LABEL;
const old = oldPluginSpec?.thresholds;
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
const c = controls as SectionControls[SectionKind.Thresholds];
const variant = c.variant ?? ThresholdVariant.LABEL;
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
?.thresholds;
if (!old || old.length === 0) {
return [];
return undefined;
}
// The save API rejects an empty table-threshold columnName.
const defaultColumnName =
variant === ThresholdVariant.TABLE
? getTableColumnKeys(oldSpec?.queries ?? [])[0]
: undefined;
const mapped = old.map((threshold) =>
toThresholdVariant(
threshold as AnyThresholdFields,
variant,
defaultColumnName,
),
return old.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, variant),
);
return variant === ThresholdVariant.TABLE
? mapped.filter((t) => (t as { columnName?: string }).columnName)
: mapped;
},
},
};
@@ -265,23 +184,14 @@ export function buildPluginSpec(
): SeededPluginSpec {
const spec: SeededPluginSpec = {};
// One localized cast for all seeds: `plugin.spec` is typed `unknown`.
const resolved: ResolvedSeedContext = {
...ctx,
oldPluginSpec: ctx.oldSpec?.plugin.spec as SeededPluginSpec | undefined,
};
sections.forEach((section) => {
const entry = SECTION_SEEDS[section.kind];
if (!entry) {
return;
}
const controls = 'controls' in section ? section.controls : undefined;
// The lookup can't prove `section.controls` matches `entry`'s key, so `entry.seed`
// is a union of differently-typed fns; one boundary cast, in place of a per-seed one.
const seed = entry.seed as (c: unknown, ctx: ResolvedSeedContext) => object;
const value = seed(controls, resolved);
if (!isEmptySlice(value)) {
const value = entry.seed(controls, ctx);
if (value !== undefined) {
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
(spec as Record<string, unknown>)[entry.specKey] = value;
}

View File

@@ -1,57 +0,0 @@
import { resolveContextLinkUrl } from '../resolveContextLinkUrl';
describe('resolveContextLinkUrl', () => {
const vars = { timestamp_start: '1720512000000', service_name: 'frontend' };
it('resolves a variable in the base URL path', () => {
expect(
resolveContextLinkUrl('https://google.com/{{timestamp_start}}', vars),
).toBe('https://google.com/1720512000000');
});
it('resolves variables in query params', () => {
expect(
resolveContextLinkUrl('https://x.com/d?service={{service_name}}', vars),
).toBe('https://x.com/d?service=frontend');
});
// Regression: a literal `%` must not abort resolution of the base-URL variable.
it('keeps resolving when a param value contains a literal %', () => {
expect(
resolveContextLinkUrl(
'https://google.com/{{timestamp_start}}?name=95%',
vars,
),
).toBe('https://google.com/1720512000000?name=95%25');
});
it('resolves both a base-URL variable and a param variable together', () => {
expect(
resolveContextLinkUrl(
'https://x.com/{{service_name}}?ts={{timestamp_start}}',
vars,
),
).toBe('https://x.com/frontend?ts=1720512000000');
});
it('leaves an unknown variable token untouched', () => {
expect(resolveContextLinkUrl('https://x.com/{{unknown}}', vars)).toBe(
'https://x.com/{{unknown}}',
);
});
it('returns the base URL unchanged when there is no query string', () => {
expect(resolveContextLinkUrl('https://x.com/plain', vars)).toBe(
'https://x.com/plain',
);
});
it('decodes double-encoded param values before resolving', () => {
expect(
resolveContextLinkUrl(
'https://x.com/d?ts=%257B%257Btimestamp_start%257D%257D',
vars,
),
).toBe('https://x.com/d?ts=1720512000000');
});
});

View File

@@ -1,41 +0,0 @@
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
// A literal `%` (e.g. `?name=95%`) isn't valid percent-encoding — fall back to the raw string.
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/**
* Resolves `{{var}}` tokens in a context-link URL. V2-local rather than the shared V1
* `processContextLinks`, which throws on a literal `%` and drops the whole URL to its template.
*/
export function resolveContextLinkUrl(
url: string,
processedVariables: Record<string, string>,
): string {
const [baseUrl, queryString] = url.split('?');
const resolvedBase = resolveTexts({ texts: [baseUrl], processedVariables })
.fullTexts[0];
if (!queryString) {
return resolvedBase;
}
const resolvedQuery = Array.from(new URLSearchParams(queryString).entries())
.map(([key, value]) => {
// Decode twice for double-encoded values; safe so a bad `%` doesn't abort.
const decoded = safeDecodeURIComponent(safeDecodeURIComponent(value));
const resolvedValue = resolveTexts({
texts: [decoded],
processedVariables,
}).fullTexts[0];
return `${encodeURIComponent(key)}=${encodeURIComponent(resolvedValue)}`;
})
.join('&');
return `${resolvedBase}?${resolvedQuery}`;
}

View File

@@ -1,7 +1,6 @@
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
/** A panel context link with its label and URL templates resolved, ready to render. */
export interface ResolvedDrilldownLink {
@@ -11,8 +10,10 @@ export interface ResolvedDrilldownLink {
}
/**
* Resolves a panel's context links for the drilldown menu: substitutes variables in each link's
* label + URL, drops links without a URL, and skips substitution when `renderVariables === false`.
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
* label/URL (no substitution).
*/
export function resolvePanelContextLinks(
links: DashboardLinkDTO[] | undefined,
@@ -23,17 +24,27 @@ export function resolvePanelContextLinks(
return [];
}
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
}));
const resolved = processContextLinks(adapted, processedVariables, 50);
return usable.map((link, index) => {
const rawLabel = link.name || link.url || '';
const rawUrl = link.url ?? '';
// Only an explicit `false` opts out; undefined defaults to substitution on.
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
if (link.renderVariables === false) {
return { id: String(index), label: rawLabel, url: rawUrl };
return {
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
};
}
return {
id: String(index),
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
url: resolveContextLinkUrl(rawUrl, processedVariables),
id: resolved[index].id,
label: resolved[index].label,
url: resolved[index].url,
};
});
}

View File

@@ -1,46 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import {
type AggregationView,
getAggregationColumnKey,
} from '../../queryV5/prepareScalarTables';
// Narrow view over any envelope spec: every variant carries name/disabled,
// only builder queries have aggregations.
interface QuerySpecView {
name?: string;
disabled?: boolean;
aggregations?: AggregationView[];
}
/**
* Keys every enabled query's value columns render under (one per aggregation on
* multi-aggregation queries), derived from the panel's queries alone — lets a kind
* switch key per-column config before any data exists. clickhouse_sql queries are
* skipped: their columns are keyed by the response's SQL alias, unknowable here.
*/
export function getTableColumnKeys(
queries: DashboardtypesQueryDTO[],
): string[] {
const keys = new Set<string>();
toQueryEnvelopes(queries).forEach((envelope) => {
if (
envelope.type ===
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql
) {
return;
}
const spec = envelope.spec as QuerySpecView | undefined;
if (!spec?.name || spec.disabled) {
return;
}
const { aggregations } = spec;
const columnCount = Math.max(aggregations?.length ?? 0, 1);
for (let index = 0; index < columnCount; index += 1) {
keys.add(getAggregationColumnKey(spec.name, aggregations, index));
}
});
return [...keys];
}

View File

@@ -131,7 +131,6 @@ function ViewPanelModalContent({
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
footer={
isListPanel ? (
<ListColumnsEditor

View File

@@ -68,7 +68,7 @@ jest.mock(
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState',
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
() => ({
ALL_SELECTED: '__ALL__',
variablesUrlParser: { withOptions: (): unknown => ({}) },

View File

@@ -1,7 +1,6 @@
import { renderHook } from '@testing-library/react';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { useScrollIntoViewStore } from '../../../../store/useScrollIntoViewStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
@@ -48,7 +47,6 @@ describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
useScrollIntoViewStore.setState({ scrollTargetId: null });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
@@ -134,21 +132,6 @@ describe('useClonePanel', () => {
);
});
it('records the cloned panel as the scroll target when the toast auto-closes', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
// The reveal is deferred to the toast's auto-close, not fired inline.
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
onAutoClose: () => void;
};
onAutoClose();
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('cloned-id');
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));

View File

@@ -10,7 +10,6 @@ import {
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
import type { DashboardSection } from '../../../utils';
interface Params {
@@ -33,7 +32,6 @@ export function useClonePanel({
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
@@ -66,9 +64,6 @@ export function useClonePanel({
success: 'Panel cloned',
error: 'Failed to clone panel',
position: 'top-center',
duration: 2000,
// Defer the reveal to the toast's auto-close so the confirmation shows first.
onAutoClose: () => setScrollTargetId(newPanelId),
});
// toast.promise owns the error UX; swallow here to avoid an unhandled
@@ -79,6 +74,6 @@ export function useClonePanel({
// no-op
}
},
[sections, dashboardId, patchAsync, setScrollTargetId],
[sections, dashboardId, patchAsync],
);
}

View File

@@ -21,7 +21,7 @@ import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer
import {
ALL_SELECTED,
variablesUrlParser,
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */

View File

@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { bottomRowSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -20,8 +20,8 @@ interface Params {
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a fresh row at
* the bottom of the target section (`bottomRowSlot`). Persisted as one atomic patch.
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
@@ -52,10 +52,12 @@ export function useMovePanelToSection({
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Land at the section bottom, not backfilled into a gap — least disruptive
// to the arrangement the user already made in the target section.
const { x, y } = bottomRowSlot(target.items);
const targetItems = [...target.items, { ...moved, x, y }];
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchAsync(

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
@@ -12,7 +12,6 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
@@ -66,9 +65,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const cloneSection = useCloneSection();
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
const grid = (
<SectionGrid
items={section.items}
@@ -81,7 +77,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
// Untitled section — just the grid, no header chrome.
return (
<div
ref={sectionRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -92,7 +87,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}

View File

@@ -3,7 +3,6 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import styles from './SectionGrid.module.scss';
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
@@ -32,7 +31,6 @@ function SectionGridItem({
VIEWPORT_OBSERVER_OPTIONS,
true,
);
useScrollIntoView(panelId, containerRef);
return (
<div ref={containerRef} className={styles.panelWrapper}>

View File

@@ -1,69 +0,0 @@
import type { RefObject } from 'react';
import { renderHook } from '@testing-library/react';
import { useScrollIntoViewStore } from '../../../../store/useScrollIntoViewStore';
import { useScrollIntoView } from '../useScrollIntoView';
function refWithScroll(scrollIntoView: jest.Mock): RefObject<HTMLElement> {
return {
current: { scrollIntoView } as unknown as HTMLElement,
} as RefObject<HTMLElement>;
}
describe('useScrollIntoView', () => {
beforeEach(() => {
useScrollIntoViewStore.setState({ scrollTargetId: null });
});
it('scrolls into view and clears the request when the store targets this id', () => {
const scrollIntoView = jest.fn();
useScrollIntoViewStore.setState({ scrollTargetId: 'p1' });
renderHook(() => useScrollIntoView('p1', refWithScroll(scrollIntoView)));
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
it('reveals a section id the same way (one hook for panels and sections)', () => {
const scrollIntoView = jest.fn();
useScrollIntoViewStore.setState({ scrollTargetId: 'sec-empty-1' });
renderHook(() =>
useScrollIntoView('sec-empty-1', refWithScroll(scrollIntoView)),
);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
it('honours the caller-provided block alignment', () => {
const scrollIntoView = jest.fn();
useScrollIntoViewStore.setState({ scrollTargetId: 'p1' });
renderHook(() =>
useScrollIntoView('p1', refWithScroll(scrollIntoView), 'center'),
);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
});
it('does nothing when the store targets a different id', () => {
const scrollIntoView = jest.fn();
useScrollIntoViewStore.setState({ scrollTargetId: 'other' });
renderHook(() => useScrollIntoView('p1', refWithScroll(scrollIntoView)));
expect(scrollIntoView).not.toHaveBeenCalled();
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('other');
});
});

View File

@@ -11,8 +11,27 @@ import {
reorderLayoutsOp,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
import { getSectionStableId } from '../../../utils';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the appended section to render, then scrolls it into view.
* Polls because the optimistic cache write commits to the DOM a frame or two after
* the patch call; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
if (sections.length > prevCount) {
sections[sections.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (attempts > 0) {
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
}
}
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
@@ -33,7 +52,6 @@ export function useAddSection({ layouts }: Params): Result {
const { patchAsync } = useOptimisticPatch();
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const addSection = useCallback(
async (title: string): Promise<void> => {
@@ -41,24 +59,22 @@ export function useAddSection({ layouts }: Params): Result {
if (!dashboardId || !trimmed) {
return;
}
const isFirstSection = !layouts || layouts.length === 0;
const op = isFirstSection
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
const op =
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchAsync([op]);
// The new empty section is appended, so its layout index is the prior count;
// key it the way `getSectionStableId` does so it reveals itself on render.
const newIndex = isFirstSection ? 0 : layouts.length;
setScrollTargetId(getSectionStableId([], newIndex));
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[layouts, dashboardId, patchAsync, showErrorModal, setScrollTargetId],
[layouts, dashboardId, patchAsync, showErrorModal],
);
return { addSection, isSaving };

View File

@@ -1,24 +0,0 @@
import { RefObject, useEffect } from 'react';
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
/**
* Scrolls this panel/section into view when the store targets its id, then clears the
* request so it fires once. Runs on mount, so it catches the element as the grid renders it.
*/
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'start',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
useEffect(() => {
if (scrollTargetId !== id) {
return;
}
ref.current?.scrollIntoView({ behavior: 'smooth', block });
setScrollTargetId(null);
}, [scrollTargetId, id, ref, block, setScrollTargetId]);
}

View File

@@ -1,135 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSeedVariableSelection } from '../useSeedVariableSelection';
const mockSetUrlValues = jest.fn();
let mockUrlValues: Record<string, unknown> | null = null;
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [mockUrlValues, mockSetUrlValues],
}));
// The hook maps spec DTOs through dtoToFormModel; identity lets tests pass form
// models directly as `spec.variables`.
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function dashboard(
id: string,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
id,
spec: { variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
function seededValue(dashboardId: string, name: string): unknown {
return useDashboardStore.getState().variableValues[dashboardId]?.[name];
}
describe('useSeedVariableSelection', () => {
afterEach(() => {
mockUrlValues = null;
mockSetUrlValues.mockClear();
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableFetchContext: null,
});
});
it('seeds from the URL over the stored value and the default', () => {
mockUrlValues = { env: 'from-url' };
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: 'stored', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'from-url',
allSelected: false,
});
});
it('falls back to the stored value when the URL has no entry', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: 'stored', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'stored',
allSelected: false,
});
});
it('falls back to the default when neither URL nor store has a value', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'default',
allSelected: false,
});
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),
model({ name: 'service', type: 'CUSTOM', customValue: 'a,b' }),
]);
renderHook(() => useSeedVariableSelection(dash));
const state = useDashboardStore.getState();
expect(state.variableFetchContext?.variableTypes).toStrictEqual({
env: 'TEXT',
service: 'CUSTOM',
});
expect(state.variableFetchStates).toStrictEqual({
env: 'idle',
service: 'idle',
});
});
it('prunes URL entries for variables that no longer exist', () => {
mockUrlValues = { env: 'prod', removed: 'stale' };
const dash = dashboard('d1', [model({ name: 'env', type: 'TEXT' })]);
renderHook(() => useSeedVariableSelection(dash));
expect(mockSetUrlValues).toHaveBeenCalledWith({ env: 'prod' });
});
it('writes nothing while the dashboard is still loading', () => {
renderHook(() => useSeedVariableSelection(undefined));
const state = useDashboardStore.getState();
expect(state.variableValues).toStrictEqual({});
expect(state.variableFetchContext).toBeNull();
});
});

View File

@@ -1,31 +0,0 @@
import { withVariablesSearch } from '../variablesUrlState';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
}));
describe('withVariablesSearch', () => {
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
'{"env":"prod"}',
)}`;
it('returns the base unchanged when the current search has no variables', () => {
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
'?panelKind=signoz/TablePanel',
);
});
it('carries only the variables param onto an empty base', () => {
const result = withVariablesSearch('', current);
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
});
it('appends the variables param to existing base params', () => {
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
const params = new URLSearchParams(result);
expect(params.get('panelKind')).toBe('signoz/TablePanel');
expect(params.get('variables')).toBe('{"env":"prod"}');
});
});

View File

@@ -1,130 +0,0 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useQueryState } from 'nuqs';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
deriveFetchContext,
type VariableFetchContext,
} from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
raw: SelectedVariableValue,
model: VariableFormModel,
): VariableSelection {
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: raw, allSelected: false };
}
interface UseSeedVariableSelectionResult {
variables: VariableFormModel[];
fetchContext: VariableFetchContext;
}
/**
* Seeds the runtime variable engine: each value (URL → persisted store →
* default) plus the fetch context panel queries scope their cache keys by.
* Mounted by the variables bar and by the panel-editor page, which renders
* without the bar — without this seed a hard refresh of the editor leaves the
* store cold and the preview fetches with unresolved variables.
*/
export function useSeedVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): UseSeedVariableSelectionResult {
const dashboardId = dashboard?.id ?? '';
const specVariables = dashboard?.spec?.variables;
const variables = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
[specVariables],
);
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
}, [dashboardId, variables]);
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables
.map((v) => v.name)
.filter((name): name is string => !!name);
initVariableFetch(names, fetchContext);
}, [dashboardId, variables, fetchContext, initVariableFetch]);
return { variables, fetchContext };
}

View File

@@ -1,18 +1,69 @@
import { useCallback, useEffect, useRef } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useQueryState } from 'nuqs';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
import { useSelector } from 'react-redux';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import { useSeedVariableSelection } from './useSeedVariableSelection';
import { doAllQueryVariablesHaveValues } from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
deriveFetchContext,
doAllQueryVariablesHaveValues,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
export const variablesUrlParser = parseAsJson<
Record<string, SelectedVariableValue>
>((v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
// Explicit ALL sentinel, or a multi-select allowing ALL with no default → ALL.
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(
raw: SelectedVariableValue,
model: VariableFormModel,
): VariableSelection {
// Only read the `__ALL__` sentinel as "ALL" for variables that support it —
// otherwise a legitimate value of "__ALL__" (e.g. a text var) is taken literally.
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
@@ -27,21 +78,25 @@ interface UseVariableSelection {
}
/**
* Runtime variable selection for the variables bar: seeds values and the fetch
* context (via useSeedVariableSelection), runs the options fetch cycle, and
* persists changes to both the store and the URL. Never writes to the dashboard
* spec.
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const { variables, fetchContext } = useSeedVariableSelection(dashboard);
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
const enqueueDescendantsBatch = useDashboardStore(
@@ -57,11 +112,48 @@ export function useVariableSelection(
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [, setUrlValues] = useQueryState(
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
@@ -71,6 +163,10 @@ export function useVariableSelection(
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables
.map((v) => v.name)
.filter((name): name is string => !!name);
initVariableFetch(names, fetchContext);
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);

View File

@@ -1,34 +0,0 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
export const variablesUrlParser = parseAsJson<
Record<string, SelectedVariableValue>
>((v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
/**
* Extends a search string with the current `?variables=` param (unchanged when
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
* it survives a refresh (V1 parity).
*/
export function withVariablesSearch(
base: string,
currentSearch: string,
): string {
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
if (!value) {
return base;
}
const params = new URLSearchParams(base);
params.set(QueryParams.variables, value);
return `?${params.toString()}`;
}

View File

@@ -4,12 +4,9 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
bottomRowSlot,
cloneSectionOps,
createDefaultPanel,
createPanelOps,
findFreeSlot,
itemsOverlap,
} from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
@@ -146,77 +143,6 @@ describe('createPanelOps', () => {
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
});
it('wraps to the bottom when the last-row slot is blocked by a taller earlier-row panel', () => {
// Regression: the last row (top-y 6) has room at x:3, but the tall right
// panel spans y:0..12 into it. Placing at x:3,y:6 would overlap it, so the
// panel must drop to a fresh row at the bottom (y:12) instead.
const layouts = [
section([
itemAt(0, 0, 6, 6),
itemAt(6, 0, 6, 12), // tall, reaches down into the last row
itemAt(0, 6, 3, 6),
]),
];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
expect(value.y).toBe(12);
});
});
describe('itemsOverlap', () => {
it('is true only when rectangles intersect on both axes', () => {
const a = { x: 0, y: 0, width: 6, height: 6 };
expect(itemsOverlap(a, { x: 3, y: 3, width: 6, height: 6 })).toBe(true);
// Touching edges do not overlap (half-open intervals).
expect(itemsOverlap(a, { x: 6, y: 0, width: 6, height: 6 })).toBe(false);
expect(itemsOverlap(a, { x: 0, y: 6, width: 6, height: 6 })).toBe(false);
// Overlaps on x only (disjoint on y) → no overlap.
expect(itemsOverlap(a, { x: 3, y: 6, width: 6, height: 6 })).toBe(false);
});
});
describe('findFreeSlot', () => {
it('places the first item at the origin', () => {
expect(findFreeSlot([], 6)).toStrictEqual({ x: 0, y: 0 });
});
it('fills the right of the last row when it fits and is clear', () => {
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 6)).toStrictEqual({ x: 6, y: 0 });
});
it('never returns a slot that overlaps an existing item', () => {
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
const slot = findFreeSlot(items, 6);
const placed = { ...slot, width: 6, height: 6 };
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
expect(slot).toStrictEqual({ x: 0, y: 12 });
});
it('clamps a too-wide panel to the grid width', () => {
// width 20 > 12 cols → clamped to 12, so it wraps below the first row.
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 20)).toStrictEqual({ x: 0, y: 6 });
});
});
describe('bottomRowSlot', () => {
it('is the origin for an empty section', () => {
expect(bottomRowSlot([])).toStrictEqual({ x: 0, y: 0 });
});
it('drops a fresh left-edge row below the tallest reaching item', () => {
// max(y + height) across items: itemAt(6,0,6,12) reaches y=12.
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12)];
expect(bottomRowSlot(items)).toStrictEqual({ x: 0, y: 12 });
});
it('never returns a slot that overlaps an existing item', () => {
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
const placed = { ...bottomRowSlot(items), width: 12, height: 6 };
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
});
});
describe('cloneSectionOps', () => {

View File

@@ -64,10 +64,4 @@ describe('useResolvedVariables', () => {
selectResolvedVariables('d3')(useDashboardStore.getState()),
).toStrictEqual({});
});
it('publishes nothing while the dashboard is still loading', () => {
renderHook(() => useResolvedVariables(undefined));
expect(useDashboardStore.getState().resolvedVariables).toStrictEqual({});
});
});

View File

@@ -1,12 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -26,7 +25,6 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -50,11 +48,9 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex, search],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -1,39 +1,32 @@
import { useCallback } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. The `?variables=` selection is carried
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
* caller can open the editor with just the panel id. The optional `handoffState` is passed as
* router location state — the View modal uses it to hand its drilldown-edited spec off to the
* editor (view → edit) so the editor opens on those edits rather than the saved panel.
*/
export function useOpenPanelEditor(): (
panelId: string,
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
})}${withVariablesSearch('', search)}`,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, search],
[safeNavigate, dashboardId],
);
}

View File

@@ -16,14 +16,13 @@ import { useDashboardStore } from '../store/useDashboardStore';
* panel queries and triggers a refetch with the new values.
*/
export function useResolvedVariables(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
dashboard: DashboardtypesGettableDashboardV2DTO,
): void {
const dashboardId = dashboard?.id ?? '';
const dashboardId = dashboard.id ?? '';
const specVariables = dashboard?.spec?.variables;
const definitions = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
[specVariables],
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));

View File

@@ -172,43 +172,9 @@ const GRID_COLS = 12;
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
/**
* Whether two grid rectangles intersect on both axes. Mirrors the backend's
* overlap check (a patch placing two intersecting items is rejected), so this is
* the authority the frontend must satisfy before adding an item.
*/
export function itemsOverlap(a: PlacedItem, b: PlacedItem): boolean {
const ax = a.x ?? 0;
const ay = a.y ?? 0;
const aw = a.width ?? 0;
const ah = a.height ?? 0;
const bx = b.x ?? 0;
const by = b.y ?? 0;
const bw = b.width ?? 0;
const bh = b.height ?? 0;
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
/**
* A fresh row below every existing item (`x: 0` at the greatest bottom-y) — sits
* under everything so it can never overlap. Used when a panel must go to the end
* (e.g. moved into a section) rather than backfilling a gap.
*/
export function bottomRowSlot(items: PlacedItem[]): { x: number; y: number } {
const bottom = items.reduce(
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
0,
);
return { x: 0, y: bottom };
}
/**
* Placement for a new grid item: drop it right of the last row if it both fits
* the grid width and clears every existing item, else wrap to a fresh row at the
* bottom (`bottomRowSlot`). Only the last row is preferred (items sharing the
* greatest top-y); gaps in earlier rows are left alone. The overlap guard is what
* keeps this safe — a tall panel from an earlier row can reach down into the last
* row, so "right of the last row" is not automatically free. This is the placement
* primitive for create / clone.
* Placement for a new grid item: drop it right of the last row if there's room,
* else wrap to a fresh row at the bottom. Only the last row is considered (items
* sharing the greatest top-y); gaps in earlier rows are left alone.
*/
export function findFreeSlot(
items: PlacedItem[],
@@ -219,25 +185,19 @@ export function findFreeSlot(
return { x: 0, y: 0 };
}
const bottom = items.reduce(
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
0,
);
const lastRowY = items.reduce((max, it) => Math.max(max, it.y ?? 0), 0);
const lastRowRightEdge = items
.filter((it) => (it.y ?? 0) === lastRowY)
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
// height is unbounded downward, so use 1 for the fit probe: overlap on the
// y-axis is decided by items reaching below `lastRowY`, not by the new
// panel's own height (its top sits at the greatest top-y of all items).
const candidate: PlacedItem = {
x: lastRowRightEdge,
y: lastRowY,
width: w,
height: 1,
};
const fitsWidth = lastRowRightEdge + w <= GRID_COLS;
if (fitsWidth && !items.some((it) => itemsOverlap(candidate, it))) {
if (lastRowRightEdge + w <= GRID_COLS) {
return { x: lastRowRightEdge, y: lastRowY };
}
return bottomRowSlot(items);
return { x: 0, y: bottom };
}
/**

View File

@@ -12,7 +12,7 @@ import type { PanelTable, PanelTableColumn } from './types';
// Narrow view over a builder-query aggregation; envelope spec is `unknown`, so naming reads
// through this view with a localized cast at the boundary.
export interface AggregationView {
interface AggregationView {
alias?: string;
expression?: string;
}
@@ -108,25 +108,8 @@ function getColName(
}
/**
* The map key a value column's data is stored and looked up under in each row —
* effectively the column id. Single-aggregation queries use the query name;
* multi-aggregation queries append `.expression` (`queryName.expression`) so the
* two value columns from one query don't collide on the same key.
*/
export function getAggregationColumnKey(
queryName: string,
aggregations: AggregationView[] | undefined,
aggregationIndex = 0,
): string {
const expression = aggregations?.[aggregationIndex]?.expression || '';
if ((aggregations?.length || 0) > 1 && expression) {
return `${queryName}.${expression}`;
}
return queryName;
}
/**
* Stable row-data key (port of V1 `getColId`).
* Stable row-data key (port of V1 `getColId`). Multi-aggregation queries need
* `queryName.expression` so value columns don't collide.
*/
function getColId(
col: Querybuildertypesv5ColumnDescriptorDTO,
@@ -146,11 +129,12 @@ function getColId(
return col.name;
}
return getAggregationColumnKey(
queryName,
aggregationsPerQuery[queryName],
col.aggregationIndex ?? 0,
);
const aggregations = aggregationsPerQuery[queryName];
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
if ((aggregations?.length || 0) > 1 && expression) {
return `${queryName}.${expression}`;
}
return queryName;
}
export interface PrepareScalarTablesArgs {

View File

@@ -1,19 +0,0 @@
import { create } from 'zustand';
/**
* One-shot reveal signal: a flow records the id of the panel/section to reveal and the
* matching component scrolls itself into view on mount (see `useScrollIntoView`), then
* clears it. Panel and section ids share this slot — they're in disjoint namespaces
* (`sec-*` vs UUIDs). Kept off `useDashboardStore`/the URL so a refresh doesn't re-fire it.
*/
export interface ScrollIntoViewStore {
scrollTargetId: string | null;
setScrollTargetId: (id: string | null) => void;
}
export const useScrollIntoViewStore = create<ScrollIntoViewStore>((set) => ({
scrollTargetId: null,
setScrollTargetId: (scrollTargetId): void => {
set({ scrollTargetId });
},
}));

View File

@@ -7,12 +7,12 @@ import {
} from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
@@ -24,9 +24,6 @@ import {
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
import styles from './PanelEditorPage.module.scss';
/**
@@ -45,30 +42,11 @@ function PanelEditorPage(): JSX.Element {
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
const { dashboard, isLoading, isError, error, refetch } =
const { dashboard, isLoading, isError, error } =
useDashboardFetch(dashboardId);
// Derived here (not from the store) because the editor route doesn't mount
// DashboardContainer, so the store's edit context may be cold on a direct URL.
const { isEditable, isLocked, canEditDashboard, editDisabledReason } =
useDashboardEditGuard(dashboard);
// On a refresh/direct URL this route is the only mount, so seed the edit
// context the way DashboardContainer does — during render, so the subtree's
// first render already sees the id (useDashboardFetchRequired throws without it).
const setEditContext = useDashboardStore((s) => s.setEditContext);
if (dashboard?.id) {
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard,
refetch,
});
}
// No variables bar on this route: seed the selection and publish the resolved
// payload so the preview and context links get variable values after a refresh.
useSeedVariableSelection(dashboard);
useResolvedVariables(dashboard);
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
@@ -98,11 +76,16 @@ function PanelEditorPage(): JSX.Element {
const backToDashboard = useCallback((): void => {
// Carry only dashboard params; drop editor-only URL state (chiefly
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
const params = new URLSearchParams();
const variables = new URLSearchParams(search).get(QueryParams.variables);
if (variables) {
params.set(QueryParams.variables, variables);
}
const query = params.toString();
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
'',
search,
)}`,
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${
query ? `?${query}` : ''
}`,
);
}, [safeNavigate, dashboardId, search]);

View File

@@ -60,7 +60,7 @@ func newConfig() factory.Config {
Path: "/var/lib/signoz/signoz.db",
Mode: "wal",
BusyTimeout: 10000 * time.Millisecond, // increasing the defaults from https://github.com/mattn/go-sqlite3/blob/master/sqlite3.go#L1098 because of transpilation from C to GO
TransactionMode: "deferred",
TransactionMode: "immediate",
},
}

View File

@@ -62,12 +62,6 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
parser.addoption(
"--sqlite-transaction-mode",
action="store",
default="deferred",
help="sqlite transaction mode",
)
parser.addoption(
"--postgres-version",
action="store",

View File

@@ -17,8 +17,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
ZEUS_NETWORK_ALIAS = "signoz-zeus"
@pytest.fixture(name="zeus", scope="package")
def zeus(
@@ -33,7 +31,6 @@ def zeus(
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
container.with_network_aliases(ZEUS_NETWORK_ALIAS)
container.start()
return types.TestContainerDocker(
@@ -45,7 +42,7 @@ def zeus(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):

View File

@@ -1,19 +1,14 @@
import os
import platform
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from http import HTTPStatus
from os import path
from pathlib import Path
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from testcontainers.core.image import DockerImage
from fixtures import reuse, types
from fixtures.logger import setup_logger
@@ -21,110 +16,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@dataclass
class SigNozImageBuild:
process: subprocess.Popen
command: list[str]
cache_path: Path | None = None
next_cache_path: Path | None = None
reader: threading.Thread | None = None
def _stream_build_output(pipe, log) -> None:
try:
for line in iter(pipe.readline, ""):
log.info("buildx: %s", line.rstrip())
finally:
pipe.close()
def start_signoz_image_build(pytestconfig: pytest.Config, dockerfile_path: str, arch: str, zeus_url: str) -> SigNozImageBuild:
root = pytestconfig.rootpath.parent
command = [
"docker",
"buildx",
"build",
"--load",
"--progress",
"plain",
"--tag",
"signoz:integration",
"--file",
dockerfile_path,
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus_url}",
str(root),
]
cache_path = None
next_cache_path = None
if os.environ.get("ACTIONS_RUNTIME_TOKEN"):
# Running in GitHub Actions — use BuildKit's native GHA cache backend.
# Avoids the local-write races and partial exports seen with type=local.
scope = os.environ.get("SIGNOZ_BUILDX_GHA_SCOPE", "signoz-integration")
command.extend(["--cache-from", f"type=gha,scope={scope}"])
command.extend(["--cache-to", f"type=gha,scope={scope},mode=max"])
elif build_cache_dir := os.environ.get("SIGNOZ_INTEGRATION_BUILD_CACHE_DIR"):
# Local cache for developer machines / non-GHA CI.
cache_path = Path(build_cache_dir)
next_cache_path = Path(f"{build_cache_dir}-next")
cache_path.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(next_cache_path, ignore_errors=True)
if cache_path.exists():
command.extend(["--cache-from", f"type=local,src={cache_path}"])
command.extend(["--cache-to", f"type=local,dest={next_cache_path},mode=max"])
logger.info("Building SigNoz integration image with %s", " ".join(command))
process = subprocess.Popen(
command,
cwd=root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
reader = threading.Thread(target=_stream_build_output, args=(process.stdout, logger), daemon=True)
reader.start()
return SigNozImageBuild(
process=process,
command=command,
cache_path=cache_path,
next_cache_path=next_cache_path,
reader=reader,
)
def wait_for_signoz_image_build(build: SigNozImageBuild) -> None:
returncode = build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
if returncode != 0:
raise subprocess.CalledProcessError(returncode, build.command)
if build.cache_path and build.next_cache_path and build.next_cache_path.exists():
shutil.rmtree(build.cache_path, ignore_errors=True)
shutil.move(build.next_cache_path, build.cache_path)
def stop_signoz_image_build(build: SigNozImageBuild) -> None:
if build.process.poll() is not None:
return
build.process.terminate()
try:
build.process.wait(timeout=10)
except subprocess.TimeoutExpired:
build.process.kill()
build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
def create_signoz(
network: Network,
zeus: types.TestContainerDocker,
@@ -142,6 +33,9 @@ def create_signoz(
"""
def create() -> types.SigNoz:
# Run the migrations for clickhouse
request.getfixturevalue("migrator")
# Get the no-web flag
with_web = pytestconfig.getoption("--with-web")
@@ -154,15 +48,19 @@ def create_signoz(
if with_web:
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
# The SigNoz image build does not depend on ClickHouse migrations, so
# build it while the migrator container runs.
image_build = start_signoz_image_build(pytestconfig, dockerfile_path, arch, zeus.container_configs["8080"].base())
try:
request.getfixturevalue("migrator")
wait_for_signoz_image_build(image_build)
except Exception: # pylint: disable=broad-exception-caught
stop_signoz_image_build(image_build)
raise
# Docker build context is the repo root — one up from pytest's
# rootdir (tests/).
self = DockerImage(
path=str(pytestconfig.rootpath.parent),
dockerfile_path=dockerfile_path,
tag="signoz:integration",
buildargs={
"TARGETARCH": arch,
"ZEUSURL": zeus.container_configs["8080"].base(),
},
)
self.build()
env = (
{

View File

@@ -30,7 +30,6 @@ def sqlite(
assert result.fetchone()[0] == 1
mode = pytestconfig.getoption("--sqlite-mode")
transaction_mode = pytestconfig.getoption("--sqlite-transaction-mode")
return types.TestContainerSQL(
container=types.TestContainerDocker(
id="",
@@ -42,7 +41,6 @@ def sqlite(
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
},
)