mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
16 Commits
platform-p
...
issue_5015
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cdd2c4e5d | ||
|
|
f7bfd0eba6 | ||
|
|
602874b75e | ||
|
|
98ea39aa60 | ||
|
|
8f4b4b0fc2 | ||
|
|
2cf61813ae | ||
|
|
887dc9de16 | ||
|
|
d0ab1b6301 | ||
|
|
1bfe614b92 | ||
|
|
592cc02df4 | ||
|
|
bf07f74185 | ||
|
|
a9bb25e085 | ||
|
|
99267e5e91 | ||
|
|
08320f5173 | ||
|
|
8562049bfe | ||
|
|
e3a22cd7cf |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -45,9 +45,15 @@ 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:
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -76,9 +76,15 @@ 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:
|
||||
|
||||
19
Makefile
19
Makefile
@@ -34,6 +34,8 @@ 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
|
||||
@@ -210,7 +212,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 && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
@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
|
||||
|
||||
.PHONY: py-test-teardown
|
||||
py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
@@ -229,6 +231,21 @@ 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -21,7 +23,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -29,7 +32,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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 mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
@@ -30,7 +32,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -38,7 +41,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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 mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -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: immediate
|
||||
transaction_mode: deferred
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
|
||||
@@ -49,6 +49,23 @@ 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', () => {
|
||||
|
||||
@@ -29,18 +29,22 @@ export function insertVariableAtCursor(
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const decoded = decodeURIComponent(value);
|
||||
// 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 {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return decoded;
|
||||
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.
|
||||
function decodeForDisplay(value: string): string {
|
||||
return safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
export function getUrlParams(url: string): UrlParam[] {
|
||||
const [, queryString] = url.split('?');
|
||||
@@ -53,7 +57,7 @@ export function getUrlParams(url: string): UrlParam[] {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: decodeURIComponent(key),
|
||||
key: safeDecodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -53,6 +56,7 @@ function ColumnUnits({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
initialValue={metricUnit}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function FormattingSection({
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useSeedMetricUnit'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,4 +100,33 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,7 @@
|
||||
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);
|
||||
|
||||
@@ -40,6 +35,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -45,6 +46,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +62,7 @@ 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];
|
||||
@@ -156,7 +160,9 @@ function PanelEditorQueryBuilder({
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,7 +20,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(undefined);
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -196,7 +202,10 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -299,6 +308,32 @@ 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'));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -247,11 +247,13 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
const withSource = (id: string, ...dataSources: string[]): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
builder: {
|
||||
queryData: dataSources.map((dataSource) => ({ dataSource })),
|
||||
},
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
@@ -286,6 +288,58 @@ 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', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ 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();
|
||||
@@ -44,7 +46,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -53,6 +55,25 @@ 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', () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -111,17 +111,27 @@ 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 dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[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]);
|
||||
|
||||
@@ -23,7 +23,8 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -44,17 +45,20 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
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: uuid(),
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -69,6 +73,7 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 };
|
||||
}
|
||||
@@ -8,25 +8,29 @@ 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';
|
||||
@@ -124,32 +128,20 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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]);
|
||||
|
||||
// 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.
|
||||
@@ -188,6 +180,17 @@ 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 => {
|
||||
@@ -203,19 +206,33 @@ 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.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [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]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -227,7 +244,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
@@ -28,14 +28,36 @@ const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
|
||||
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
function oldSpecWith(
|
||||
pluginSpec: unknown,
|
||||
queries: 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([]);
|
||||
@@ -47,16 +69,15 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
|
||||
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
]);
|
||||
@@ -112,6 +133,108 @@ 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', () => {
|
||||
@@ -137,6 +260,30 @@ 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)', () => {
|
||||
@@ -154,7 +301,7 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
|
||||
it('drops the panel-wide unit when no column keys are derivable (TimeSeries → Table, no queries)', () => {
|
||||
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -171,6 +318,56 @@ 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 } },
|
||||
@@ -225,12 +422,14 @@ 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 }) })
|
||||
.thresholds;
|
||||
return buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ thresholds }, queries),
|
||||
}).thresholds;
|
||||
}
|
||||
|
||||
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
|
||||
@@ -258,27 +457,55 @@ describe('buildPluginSpec', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves existing operator/format when remapping comparison → table', () => {
|
||||
it('preserves operator/format and keys onto the first query column when remapping comparison → table', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]),
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: '',
|
||||
columnName: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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, [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
type ControlledSectionKind,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -20,13 +21,18 @@ 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'>;
|
||||
formatting?: Pick<
|
||||
PanelFormattingSlice,
|
||||
'unit' | 'decimalPrecision' | 'columnUnits'
|
||||
>;
|
||||
selectFields?: SectionSpecMap[SectionKind.Columns];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
@@ -37,6 +43,11 @@ 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;
|
||||
@@ -51,6 +62,7 @@ interface AnyThresholdFields {
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
defaultColumnName?: string,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
@@ -69,7 +81,7 @@ function toThresholdVariant(
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName ?? '',
|
||||
columnName: source.columnName || defaultColumnName || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -79,97 +91,166 @@ function toThresholdVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
interface SectionSeed {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (controls: unknown, ctx: SeedContext) => unknown;
|
||||
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;
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
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 }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
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 }
|
||||
: {};
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.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 ?? {};
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
if (controls.lineStyle) {
|
||||
appearance.lineStyle = lineStyle;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
if (controls.lineInterpolation) {
|
||||
appearance.lineInterpolation = lineInterpolation;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
if (controls.fillMode) {
|
||||
appearance.fillMode = fillMode;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
if (controls.showPoints && showPoints !== undefined) {
|
||||
appearance.showPoints = showPoints;
|
||||
}
|
||||
if (controls.spanGaps && spanGaps !== undefined) {
|
||||
appearance.spanGaps = spanGaps;
|
||||
}
|
||||
return appearance;
|
||||
},
|
||||
},
|
||||
[SectionKind.Formatting]: {
|
||||
specKey: 'formatting',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Formatting];
|
||||
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
?.formatting;
|
||||
{ oldSpec, oldPluginSpec },
|
||||
): NonNullable<SeededPluginSpec['formatting']> => {
|
||||
const old = oldPluginSpec?.formatting;
|
||||
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
|
||||
// else the save API rejects the spec.
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(c.decimals &&
|
||||
const carried: NonNullable<SeededPluginSpec['formatting']> = {
|
||||
...(controls.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(controls.decimals &&
|
||||
old?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: old.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
return Object.keys(carried).length > 0 ? carried : undefined;
|
||||
// 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;
|
||||
},
|
||||
},
|
||||
[SectionKind.Columns]: {
|
||||
specKey: 'selectFields',
|
||||
seed: (
|
||||
_controls,
|
||||
{ signal },
|
||||
): SectionSpecMap[SectionKind.Columns] | undefined => {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
return columns.length > 0 ? columns : undefined;
|
||||
},
|
||||
seed: (_controls, { signal }): SectionSpecMap[SectionKind.Columns] =>
|
||||
signal ? defaultColumnsForSignal(signal) : [],
|
||||
},
|
||||
[SectionKind.Thresholds]: {
|
||||
specKey: 'thresholds',
|
||||
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Thresholds];
|
||||
const variant = c.variant ?? ThresholdVariant.LABEL;
|
||||
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
|
||||
?.thresholds;
|
||||
seed: (controls, { oldSpec, oldPluginSpec }): AnyThreshold[] => {
|
||||
const variant = controls.variant ?? ThresholdVariant.LABEL;
|
||||
const old = oldPluginSpec?.thresholds;
|
||||
if (!old || old.length === 0) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
return old.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, variant),
|
||||
// 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 variant === ThresholdVariant.TABLE
|
||||
? mapped.filter((t) => (t as { columnName?: string }).columnName)
|
||||
: mapped;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -184,14 +265,23 @@ 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;
|
||||
const value = entry.seed(controls, ctx);
|
||||
if (value !== 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)) {
|
||||
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
|
||||
(spec as Record<string, unknown>)[entry.specKey] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
|
||||
|
||||
/** A panel context link with its label and URL templates resolved, ready to render. */
|
||||
export interface ResolvedDrilldownLink {
|
||||
@@ -10,10 +11,8 @@ export interface ResolvedDrilldownLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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`.
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardLinkDTO[] | undefined,
|
||||
@@ -24,27 +23,17 @@ 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) => {
|
||||
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
|
||||
const rawLabel = link.name || link.url || '';
|
||||
const rawUrl = link.url ?? '';
|
||||
// Only an explicit `false` opts out; undefined defaults to substitution on.
|
||||
if (link.renderVariables === false) {
|
||||
return {
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
};
|
||||
return { id: String(index), label: rawLabel, url: rawUrl };
|
||||
}
|
||||
return {
|
||||
id: resolved[index].id,
|
||||
label: resolved[index].label,
|
||||
url: resolved[index].url,
|
||||
id: String(index),
|
||||
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
|
||||
url: resolveContextLinkUrl(rawUrl, processedVariables),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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];
|
||||
}
|
||||
@@ -131,6 +131,7 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
|
||||
@@ -68,7 +68,7 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState',
|
||||
() => ({
|
||||
ALL_SELECTED: '__ALL__',
|
||||
variablesUrlParser: { withOptions: (): unknown => ({}) },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -47,6 +48,7 @@ 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 () => {
|
||||
@@ -132,6 +134,21 @@ 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() }));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
@@ -32,6 +33,7 @@ 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> => {
|
||||
@@ -64,6 +66,9 @@ 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
|
||||
@@ -74,6 +79,6 @@ export function useClonePanel({
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, patchAsync],
|
||||
[sections, dashboardId, patchAsync, setScrollTargetId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
|
||||
|
||||
interface UseDrilldownDashboardVariablesArgs {
|
||||
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { bottomRowSlot, 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 free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
* 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.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,12 +52,10 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// 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 }];
|
||||
// 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 }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -65,6 +66,9 @@ 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}
|
||||
@@ -77,6 +81,7 @@ 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}
|
||||
>
|
||||
@@ -87,6 +92,7 @@ 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}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 = {
|
||||
@@ -31,6 +32,7 @@ function SectionGridItem({
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
useScrollIntoView(panelId, containerRef);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -11,27 +11,8 @@ import {
|
||||
reorderLayoutsOp,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import { getSectionStableId } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
@@ -52,6 +33,7 @@ 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> => {
|
||||
@@ -59,22 +41,24 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
const isFirstSection = !layouts || layouts.length === 0;
|
||||
const op = isFirstSection
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
// 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));
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, patchAsync, showErrorModal],
|
||||
[layouts, dashboardId, patchAsync, showErrorModal, setScrollTargetId],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
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"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,69 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { 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 {
|
||||
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 };
|
||||
}
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
import { doAllQueryVariablesHaveValues } from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
@@ -78,25 +27,21 @@ interface UseVariableSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
const { variables, fetchContext } = useSeedVariableSelection(dashboard);
|
||||
|
||||
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(
|
||||
@@ -112,48 +57,11 @@ export function useVariableSelection(
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
const [, 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(
|
||||
@@ -163,10 +71,6 @@ 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),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
bottomRowSlot,
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -143,6 +146,77 @@ 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', () => {
|
||||
|
||||
@@ -64,4 +64,10 @@ describe('useResolvedVariables', () => {
|
||||
selectResolvedVariables('d3')(useDashboardStore.getState()),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('publishes nothing while the dashboard is still loading', () => {
|
||||
renderHook(() => useResolvedVariables(undefined));
|
||||
|
||||
expect(useDashboardStore.getState().resolvedVariables).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } 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;
|
||||
@@ -25,6 +26,7 @@ interface UseCreatePanelResult {
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
@@ -48,9 +50,11 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
safeNavigate(
|
||||
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
[safeNavigate, dashboardId, layoutIndex, search],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } 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 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 `?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.
|
||||
*/
|
||||
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 }),
|
||||
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
})}${withVariablesSearch('', search)}`,
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
[safeNavigate, dashboardId, search],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ import { useDashboardStore } from '../store/useDashboardStore';
|
||||
* panel queries and triggers a refetch with the new values.
|
||||
*/
|
||||
export function useResolvedVariables(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): void {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const definitions = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
@@ -172,9 +172,43 @@ const GRID_COLS = 12;
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
@@ -185,19 +219,25 @@ 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);
|
||||
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
// 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))) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return { x: 0, y: bottom };
|
||||
return bottomRowSlot(items);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
interface AggregationView {
|
||||
export interface AggregationView {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
}
|
||||
@@ -108,8 +108,25 @@ function getColName(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable row-data key (port of V1 `getColId`). Multi-aggregation queries need
|
||||
* `queryName.expression` so value columns don't collide.
|
||||
* 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`).
|
||||
*/
|
||||
function getColId(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
@@ -129,12 +146,11 @@ function getColId(
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
return queryName;
|
||||
return getAggregationColumnKey(
|
||||
queryName,
|
||||
aggregationsPerQuery[queryName],
|
||||
col.aggregationIndex ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrepareScalarTablesArgs {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
@@ -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,6 +24,9 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -42,11 +45,30 @@ 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 } =
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
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, editDisabledReason } = useDashboardEditGuard(dashboard);
|
||||
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);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
@@ -76,16 +98,11 @@ 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 })}${
|
||||
query ? `?${query}` : ''
|
||||
}`,
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
|
||||
'',
|
||||
search,
|
||||
)}`,
|
||||
);
|
||||
}, [safeNavigate, dashboardId, search]);
|
||||
|
||||
|
||||
@@ -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: "immediate",
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@ 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",
|
||||
|
||||
5
tests/fixtures/http.py
vendored
5
tests/fixtures/http.py
vendored
@@ -17,6 +17,8 @@ from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ZEUS_NETWORK_ALIAS = "signoz-zeus"
|
||||
|
||||
|
||||
@pytest.fixture(name="zeus", scope="package")
|
||||
def zeus(
|
||||
@@ -31,6 +33,7 @@ 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(
|
||||
@@ -42,7 +45,7 @@ def zeus(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
|
||||
136
tests/fixtures/signoz.py
vendored
136
tests/fixtures/signoz.py
vendored
@@ -1,14 +1,19 @@
|
||||
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
|
||||
@@ -16,6 +21,110 @@ 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,
|
||||
@@ -33,9 +142,6 @@ 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")
|
||||
|
||||
@@ -48,19 +154,15 @@ def create_signoz(
|
||||
if with_web:
|
||||
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
|
||||
|
||||
# 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()
|
||||
# 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
|
||||
|
||||
env = (
|
||||
{
|
||||
|
||||
2
tests/fixtures/sqlite.py
vendored
2
tests/fixtures/sqlite.py
vendored
@@ -30,6 +30,7 @@ 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="",
|
||||
@@ -41,6 +42,7 @@ def sqlite(
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
|
||||
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
|
||||
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user