Compare commits

...

25 Commits

Author SHA1 Message Date
Abhi Kumar
2a878fa7d2 chore: restructured files 2026-06-10 02:12:25 +05:30
Abhi Kumar
c2b6fe7948 chore: restructured files 2026-06-10 02:04:20 +05:30
Abhi Kumar
1c631daf43 chore: removed non-required changes 2026-06-10 00:56:20 +05:30
Abhi Kumar
60aeac49bc chore: removed non-required changes 2026-06-10 00:52:16 +05:30
Abhi Kumar
214f2e9483 chore: removed docker compose override file 2026-06-10 00:34:07 +05:30
Abhi Kumar
064bb4d1d4 chore: minor cleanup 2026-06-10 00:30:57 +05:30
Abhi Kumar
f4fe048210 chore: fmt fix + panel fetch fix 2026-06-10 00:28:43 +05:30
Abhi Kumar
f31a63c268 refactor(dashboards-v2): move panel rendering into DashboardPageV2 structure
The dashboard page now lives under pages/DashboardPageV2/DashboardContainer;
container/DashboardContainerV2 was dead. Move the panel-rendering infra into
the new structure, wire the panel to render, and delete the old container.

- git mv Panels/ (registry + renderers + config/data/sections + utils + hooks),
  queryAdapter/, and hooks/usePanelQuery into the new DashboardContainer;
  rewrite the absolute imports old -> new path.
- Panel.tsx: render via getPanelDefinition + usePanelQuery (loading and
  unknown-kind states, cursor-sync preference, drag-select); lazy-fetch gated
  on isVisible.
- Remove a stray `debugger` in getPanelDefinition and an unused import in
  toPerses.
- Fix the persesRoundTrip testdata path for the deeper dir, and align the
  fromPerses/toPerses unit tests to the backend contract (time_series/raw
  outer query kinds, per the canonical perses.json testdata).
- Delete the dead container/DashboardContainerV2.
2026-06-10 00:28:43 +05:30
Abhi Kumar
1bf8ba7f36 feat(dashboards-v2): adopt shared Pie chart in PiePanel
Wire the V2 PiePanel renderer to the shared @visx Pie chart: pass the panel id
(for per-slice hide/unhide persistence) and the resolved legend position.

Carries the shared chart changes the panel depends on — the presentational
Legend + UPlotLegend split and the reusable Pie chart (charts/Pie) — mirrored
from #11583; these reconcile when this branch rebases onto the merged PR.
2026-06-10 00:28:41 +05:30
Abhi Kumar
9fd9fcdb3d feat(dashboards-v2): port PieChartPanel renderer
Add the V2 PieChartPanel — the first non-uPlot panel kind.

- Shared visx donut component (charts/Pie/) with PieSlice/PieChartProps,
  self-measuring draw area, leader labels, centre total, interactive legend
  and a cursor-following tooltip.
- V2 PiePanel/ (renderer + data + sections) registered in the panel registry.
- preparePieData reads the reduced value from the scalar table
  (result[].table, fallback newResult) since a pie issues a TABLE request.
- Renderer emits the typed PieClickEvent ({ label, value }).
2026-06-10 00:26:35 +05:30
Abhi Kumar
5e49566c4b feat(dashboards-v2): port HistogramPanel renderer + fix histogram requestType
Renderer reads from the perses panel spec
(DashboardtypesHistogramPanelSpecDTO) and reuses the shared utils added
with TimeSeriesPanel. Two pieces are Histogram-specific:

- HistogramPanel/data.ts (prepareHistogramData) — bins raw series values
  into a uPlot-aligned histogram. Reuses V1's bucket primitives
  (buildHistogramBuckets, mergeAlignedDataTables, ...). Mirrors V1's
  prepareHistogramPanelData with named helpers.
- HistogramPanel/config.ts — overrides x/y scales (time: false, auto:
  true) and cursor (no drag, tight focus) after buildBaseConfig, since
  histograms have no time axis. spec.visualization?.mergeAllActiveQueries
  collapses to a single merged series.

Fix: usePanelQuery now routes the resolved PANEL_TYPES through
getGraphType before passing it as graphType. Without this, HISTOGRAM was
sent as requestType 'distribution' by V5's mapPanelTypeToRequestType — a
shape prepareHistogramData can't bin. The remap (HISTOGRAM and BAR ->
TIME_SERIES) matches V1's behavior. Tests pin both remaps.

Registers signoz/HistogramPanel in Panels/index.ts.
2026-06-10 00:22:50 +05:30
Abhi Kumar
20e0bad07b feat(dashboards-v2): port BarPanel renderer
Reuses the shared infrastructure introduced with TimeSeriesPanel
(useTimeScale, useGroupByPerQuery, baseConfigBuilder, resolveSeriesLabel,
chartAppearanceMappings).

Bar-specific config in BarPanel/config.ts:

- spec.visualization.stackedBarChart → setBands(getInitialStackedBands(...))
  before the series loop.
- Per-series stepInterval read from
  apiResponse.payload.data.newResult.meta.stepIntervals[queryName] so bar
  widths match the actual sampling cadence per query.
- DrawStyle.Bar (no chartAppearance — the perses bar spec carries none).

Registers signoz/BarChartPanel in Panels/index.ts.
2026-06-10 00:22:50 +05:30
Abhi Kumar
d0029d3b40 feat(dashboards-v2): port TimeSeriesPanel renderer + shared infra
Reads from the perses panel spec (DashboardtypesTimeSeriesPanelSpecDTO) and
v5 BuilderQuery types directly — no bespoke summary types between adapter
layers.

Shared infrastructure (used by every V2 visualization panel):

- utils/baseConfigBuilder — chart scaffolding (scales, thresholds, axes,
  drag-zoom, click). onDragSelect is optional so non-time panels can opt
  out without passing a no-op.
- utils/getBuilderQueries — flattens panel.spec.queries (top-level +
  CompositeQuery envelopes) into a list of v5 BuilderQuery.
- utils/resolveSeriesLabel — V1 legend matrix on v5 types.
- utils/chartAppearanceMappings — perses ↔ uPlot enum bridges plus
  resolveDecimalPrecision / resolveSpanGaps / resolveLegendPosition.
- hooks/useTimeScale — derives X-axis clamps from the query-range
  response; isolates the data.params unknown→QueryRangeRequestV5 cast.
- hooks/useGroupByPerQuery — converts v5 GroupByKey to the V1
  BaseAutocompleteData shape the TimeSeries chart and tooltip consume.
2026-06-10 00:22:50 +05:30
Abhi Kumar
840d62f7a1 feat(dashboards-v2): wire PanelV2 to query adapter via usePanelQuery hook
Extracts panel fetch wiring out of PanelV2 into a dedicated hook that owns
the fromPerses adapter call, global time selection, and useGetQueryRange
invocation with a composed cache key. Auto-disables the fetch when the
panel has no queries so the unknown-kind fallback never triggers a wasted
request. Adds 11 unit tests covering adapter passthrough, graphType
derivation, V5 entity version, data/error propagation, loading/fetching
combination, and cache key composition.
2026-06-10 00:22:50 +05:30
Abhi Kumar
3383c5f499 feat(dashboards-v2): add perses ↔ V1 query adapter
Bridges the perses on-wire query envelope to the V1 in-memory Query shape
QueryBuilderContext consumes (and back). Lets saved V2 panels be edited and
rendered without refactoring the QB context — which is shared across
dashboards, alerts, and the explorer, so refactoring is not an option.

Architecture
- toPerses(query, graphType): V1 → DashboardtypesQueryDTO[]
  Reuses prepareQueryRangePayloadV5's existing v5 envelope conversion (and
  with it the legacy → v5 field renames). Wraps the resulting envelopes into
  the perses outer DTO with one of the 6 SigNoz plugin kinds per the
  collapse rules below. Backend invariant respected: always returns at most
  one envelope (panel.queries.length === 1).

- fromPerses(persesQueries): DashboardtypesQueryDTO[] → Query
  Per-plugin-kind dispatch with inverse field renames (name → key,
  fieldDataType → dataType, fieldContext → type; {key.name, direction} →
  {columnName, order}). Always inflates to the full
  {queryData, queryFormulas, queryTraceOperator} shape so the QB UI never
  branches on "is the builder hydrated yet."

Plugin kind coverage (all 6, both directions)
- signoz/BuilderQuery   (bare; single builder query)
- signoz/CompositeQuery (multi/mixed builder queries + formulas + trace ops)
- signoz/PromQLQuery    (bare on single; CompositeQuery on multi)
- signoz/ClickHouseSQL  (bare on single; CompositeQuery on multi)
- signoz/Formula        (only valid as composite subquery, never bare)
- signoz/TraceOperator  (only valid as composite subquery, never bare)

Semantic invariant guards
Formulas (`A + 1`) and trace operators (`A && B`) reference builder queries
by name; they cannot exist alone.
- toPerses throws on save-time violation so corrupt state can't be persisted.
- fromPerses console.warn-and-drops on read so existing/legacy/manually-
  edited dashboards still load with a clear diagnostic.

Outer envelope kind
- LIST panel → LogQuery (rows-oriented data; covers both logs and traces)
- everything else → TimeSeriesQuery
Only these two values appear anywhere in the backend testdata or code; there
is no TraceQuery kind.

Typing discipline
- All helpers signature against the local v5 types from
  types/api/v5/queryRange — no Record<string, unknown> in adapter code.
- One nominal cast per plugin-kind branch bridges the perses-generated DTO
  union to the structurally-identical local v5 union.
- v5 Step (string | number) → V1 stepInterval (number | null) explicitly
  coerces strings to null (documented in source).
- v5 QueryBuilderFormula on the wire carries `disabled` even though the
  local interface omits it — handled with `& { disabled?: boolean }`
  intersection at the helper signature.
- Defensive guards: skip when plugin.spec or subquery.spec is missing so
  malformed inputs don't crash the helpers.

Tests (70)
- toPerses unit tests (Phases 1–6): empty contract, bare BuilderQuery
  (signals + renames + filter + outer kind), CompositeQuery wrapping,
  formula/trace-op invariant throws, PromQL/ClickHouse single + multi,
  unrecognized queryType fallthrough.
- fromPerses unit tests (Phases 1–6): empty/missing/unknown plugin,
  BuilderQuery (signals + renames + filter + stepInterval string
  defensiveness), CompositeQuery distribution, top-level Formula/TraceOp
  warn-and-drop, queryType resolution for all-promql/all-clickhouse/mixed,
  malformed spec defensiveness, defensive single-DTO consumption.
- Round-trip tests cover bare BuilderQuery, multi-builder CompositeQuery,
  mixed composite (builder + formula + trace-op), bare PromQL, bare
  ClickHouseSQL, all-promql composite (PROM queryType preserved).
- Fixture suite (Phase 7) round-trips every panel in
  pkg/types/dashboardtypes/testdata/perses.json — covers every panel kind ×
  query plugin combination the backend actually emits.
2026-06-10 00:22:50 +05:30
Abhi Kumar
1d39b84f66 feat(dashboards-v2): add panel plugin registry
Foundational scaffolding for V2 panel rendering. Each panel kind (perses
plugin.kind) registers a Renderer + section config + supported signals into
PANELS; the dashboard shell dispatches generically via getPanelDefinition.

- PanelKind union + SpecForKind<K> conditional mapping each kind to its
  concrete perses spec
- PanelRegistry (keyed by kind) — cast-free registration; getPanelDefinition
  helper localizes the unavoidable widening cast for polymorphic lookup
- SECTIONS metadata table (title + icon per section) as the single source
  of truth for section kinds; SectionKind / SectionControls / SectionConfig
  derive from it
- TimeSeriesPanel stub renderer + declarative sections list
2026-06-10 00:22:50 +05:30
Ashwin Bhatkal
d7f7e81d3f feat: dashboard configuration 2026-06-10 00:22:50 +05:30
Ashwin Bhatkal
27b29262c3 feat: dashboard configuration 2026-06-10 00:22:50 +05:30
Ashwin Bhatkal
8c6b85c383 refactor(dashboards-list-v2): group state components under states/
Move EmptyState, ErrorState, LoadingState, and NoResultsState under
components/states/. They're a coherent family (interchangeable view
branches in the list orchestrator) and grouping them sets up shared
styling extraction next.

Pure relocation — git mv preserves history; only DashboardsList.tsx's
imports change.
2026-06-10 00:22:50 +05:30
Ashwin Bhatkal
d239c3cee5 feat(dashboards-list-v2): loading / error / empty / no-results state components 2026-06-10 00:22:50 +05:30
swapnil-signoz
73c2c15200 feat: adding support for VMs in Azure integration (#11573)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: adding supoprt for virtual machines to azure one click integration

* chore: generating openapi spec

* chore: updating dashboard title
2026-06-09 15:12:57 +00:00
Aditya Singh
34203c781f feat(traces): Noz button on trace detail page (#11624)
* feat: add noz ai button

* feat: badge color fix

* feat: revert badge change
2026-06-09 14:34:43 +00:00
swapnil-signoz
1ba9b90855 feat: adding assets and dashboards for azure app services (#11565)
* feat: adding assets and dashboards for azure app services

* refactor: updating metric names

* chore: openapi spec changes

* chore: generating frontend types
2026-06-09 14:30:53 +00:00
SagarRajput-7
927951b67a fix(billing): fix cancel subscription flow failing silently for users without a default mail client (#11622)
* fix(billing): fix cancel subscription flow failing silently for users without a default mail client

* fix(billing): refactor test case

* fix(billing): added log event to the copy template and reopen button

* fix(billing): test case refactor

* fix(billing): use native <a> for mailto retry to maximize cross-browser reliability
2026-06-09 14:18:43 +00:00
Vinicius Lourenço
05ad8d113d chore(vite.config): ensure commits information for sentry (#11623)
* chore(vite.config): ensure commits information is added on new release

* ci(build-staging): enable sentry on staging envs
2026-06-09 13:54:19 +00:00
67 changed files with 9593 additions and 128 deletions

View File

@@ -64,6 +64,10 @@ jobs:
run: |
mkdir -p frontend
echo 'CI=1' > frontend/.env
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env

View File

@@ -1360,6 +1360,8 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- virtualmachine
- appservice
- containerapp
- aks
type: string

View File

@@ -2651,6 +2651,8 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
virtualmachine = 'virtualmachine',
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
}

View File

@@ -70,6 +70,7 @@ export const AIAssistantOpenSource = {
Icon: 'icon',
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -67,3 +67,40 @@
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}
.fallbackBody {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.fallbackHint {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
}
.fallbackEmail {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--l1-foreground);
word-break: break-all;
}
.fallbackActions {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
padding-top: var(--padding-4);
}
.retryLink {
box-sizing: border-box;
text-decoration: none;
&:hover {
text-decoration: none;
}
}

View File

@@ -9,7 +9,37 @@ jest.mock('utils/basePath', () => ({
getBaseUrl: (): string => 'https://test.signoz.io',
}));
function mockMailto(): {
mockClick: jest.Mock;
appendSpy: jest.SpyInstance;
removeSpy: jest.SpyInstance;
} {
const mockClick = jest.fn();
const realCreateElement = document.createElement.bind(document);
// Create a real anchor so JSDOM's appendChild/removeChild accept it.
// Override its click() so no navigation occurs.
jest
.spyOn(document, 'createElement')
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
if (tag === 'a') {
const anchor = realCreateElement('a') as HTMLAnchorElement;
anchor.click = mockClick;
return anchor;
}
return realCreateElement(tag, options);
});
const appendSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(document.body, 'removeChild');
return { mockClick, appendSpy, removeSpy };
}
describe('CancelSubscriptionBanner', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -35,12 +65,10 @@ describe('CancelSubscriptionBanner', () => {
screen.getByText(/Cancelling your subscription would stop your data/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter the word cancel/i),
).toBeInTheDocument();
expect(screen.getByTestId('cancel-confirm-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel subscription/i }),
screen.getByTestId('cancel-subscription-confirm-btn'),
).toBeInTheDocument();
});
@@ -52,12 +80,10 @@ describe('CancelSubscriptionBanner', () => {
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
@@ -73,7 +99,7 @@ describe('CancelSubscriptionBanner', () => {
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
@@ -84,19 +110,11 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') {
return mockAnchor as unknown as HTMLAnchorElement;
}
return realCreateElement(tag);
});
it('fires mailto via DOM-attached anchor and shows fallback view after confirming', async () => {
const { mockClick, appendSpy, removeSpy } = mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -104,18 +122,85 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
const appendedAnchor = appendSpy.mock.calls
.map(([node]) => node)
.find(
(node): node is HTMLAnchorElement =>
node instanceof HTMLAnchorElement && node.href.startsWith('mailto:'),
);
expect(appendedAnchor).toBeDefined();
expect(mockClick).toHaveBeenCalledTimes(1);
expect(removeSpy.mock.calls.some(([node]) => node === appendedAnchor)).toBe(
true,
);
expect(
screen.getByText(/An email draft has been opened/i),
).toBeInTheDocument();
expect(screen.getByText('cloud-support@signoz.io')).toBeInTheDocument();
expect(screen.getByTestId('copy-email-template-btn')).toBeInTheDocument();
expect(screen.getByTestId('retry-mailto-btn')).toBeInTheDocument();
});
it('copies email template to clipboard when Copy button is clicked', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
expect(mockClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId('copy-email-template-btn'));
jest.restoreAllMocks();
await waitFor(() =>
expect(screen.getByTestId('copy-email-template-btn')).toHaveTextContent(
'Copied!',
),
);
});
it('retry link is a native anchor with correct mailto href in fallback view', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
const retryLink = screen.getByTestId('retry-mailto-btn');
expect(retryLink.tagName).toBe('A');
expect(retryLink).toHaveAttribute(
'href',
expect.stringContaining('mailto:cloud-support@signoz.io'),
);
});
it('closes fallback view when Close is clicked and resets state', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
await user.click(screen.getByRole('button', { name: /close/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
});
});

View File

@@ -1,27 +1,100 @@
import { useState } from 'react';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { useEffect, useRef, useState } from 'react';
import {
CircleCheck,
Copy,
MailOpen,
SolidInfoCircle,
Undo2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
import styles from './CancelSubscriptionBanner.module.scss';
const SUPPORT_EMAIL = 'cloud-support@signoz.io';
const MAX_MAILTO_URI_LENGTH = 1800;
type DialogView = 'confirm' | 'fallback';
function buildEmailBody(orgName: string, userEmail: string): string {
return [
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${userEmail}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n');
}
function buildMailtoUri(orgName: string, userEmail: string): string {
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const body = encodeURIComponent(buildEmailBody(orgName, userEmail));
const full = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`;
if (full.length <= MAX_MAILTO_URI_LENGTH) {
return full;
}
const shortBody = encodeURIComponent(
'Hi SigNoz Team,\n\nI would like to cancel my SigNoz Cloud subscription.\nPlease find my account details and reason for cancellation below.\n\n[Your details here]\n\nRegards,',
);
return `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${shortBody}`;
}
function openMailto(uri: string): void {
const link = document.createElement('a');
link.href = uri;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [dialogView, setDialogView] = useState<DialogView | null>(null);
const [confirmText, setConfirmText] = useState('');
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { user, org } = useAppContext();
useEffect(
() => (): void => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
},
[],
);
const orgName = org?.[0]?.displayName ?? '';
const userEmail = user?.email ?? '';
const handleOpenCancelDialog = (): void => {
void logEvent('Billing : Cancel Subscription Clicked', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
setOpen(true);
setDialogView('confirm');
};
const handleContactSupport = (): void => {
@@ -29,43 +102,41 @@ function CancelSubscriptionBanner(): JSX.Element {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const orgName = org?.[0]?.displayName ?? '';
const body = encodeURIComponent(
[
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${user?.email ?? ''}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n'),
);
const link = document.createElement('a');
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
openMailto(buildMailtoUri(orgName, userEmail));
setConfirmText('');
setDialogView('fallback');
};
const handleCopyTemplate = (): void => {
void logEvent('Billing : Cancel Subscription Email Template Copied', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
copyToClipboard(buildEmailBody(orgName, userEmail));
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
};
const handleRetryMailto = (): void => {
void logEvent('Billing : Cancel Subscription Email Client Reopened', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
};
const handleClose = (): void => {
setOpen(false);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
setDialogView(null);
setConfirmText('');
setCopied(false);
};
const footer = (
const confirmFooter = (
<>
<Button
variant="solid"
@@ -81,12 +152,19 @@ function CancelSubscriptionBanner(): JSX.Element {
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
data-testid="cancel-subscription-confirm-btn"
>
Cancel subscription
</Button>
</>
);
const fallbackFooter = (
<Button variant="solid" color="secondary" onClick={handleClose}>
Close
</Button>
);
return (
<>
<div className={styles.banner}>
@@ -111,27 +189,67 @@ function CancelSubscriptionBanner(): JSX.Element {
</Button>
</div>
<DialogWrapper
open={open}
open={dialogView !== null}
onOpenChange={handleClose}
title="Cancel your subscription?"
width="narrow"
showCloseButton={false}
footer={footer}
footer={dialogView === 'confirm' ? confirmFooter : fallbackFooter}
>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
{dialogView === 'confirm' && (
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
data-testid="cancel-confirm-input"
/>
</div>
)}
{dialogView === 'fallback' && (
<div className={styles.fallbackBody}>
<p className={styles.fallbackHint}>
An email draft has been opened. If it did not open, send your
cancellation request directly to:
</p>
<span className={styles.fallbackEmail}>{SUPPORT_EMAIL}</span>
<div className={styles.fallbackActions}>
<Button
variant="outlined"
color="secondary"
prefix={copied ? <CircleCheck size={14} /> : <Copy size={14} />}
onClick={handleCopyTemplate}
data-testid="copy-email-template-btn"
>
{copied ? 'Copied!' : 'Copy email template'}
</Button>
<Button
asChild
variant="outlined"
color="secondary"
data-testid="retry-mailto-btn"
>
<a
href={buildMailtoUri(orgName, userEmail)}
onClick={handleRetryMailto}
className={styles.retryLink}
target="_blank"
rel="noopener noreferrer"
>
<MailOpen size={14} />
Reopen email client
</a>
</Button>
</div>
</div>
)}
</DialogWrapper>
</>
);

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
interface TimeScale {
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
}
/**
* Derives the X-axis time-scale clamps from a query-range response. Reads
* `start`/`end` off `data.params` (the request that produced this payload)
* so each panel pins to the window it actually fetched — important during
* drag-zoom transitions when the time picker has moved but new data hasn't
* arrived yet. Falls back to the global time picker via the helper when
* `data` is absent.
*/
export function useTimeScale(
data: MetricQueryRangeSuccessResponse | undefined,
): TimeScale {
return useMemo(() => {
// `data.params` is typed `unknown` on this branch; PR 11562 narrows it
// to `QueryRangeRequestV5`. Drop this cast when that lands.
const params = data?.params as QueryRangeRequestV5 | undefined;
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(params);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data]);
}

View File

@@ -0,0 +1,34 @@
import { definition as barChart } from './kinds/BarChartPanel/definition';
import { definition as histogram } from './kinds/HistogramPanel/definition';
import { definition as pieChart } from './kinds/PieChartPanel/definition';
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
// single entry below — no other central file needs editing.
export const PANELS: PanelRegistry = {
[timeSeries.kind]: timeSeries,
[barChart.kind]: barChart,
[histogram.kind]: histogram,
[pieChart.kind]: pieChart,
};
export function getPanelDefinition(
kind: string | undefined,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind as PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -0,0 +1,152 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import { useTimeScale } from '../../hooks/useTimeScale';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildBarChartConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -0,0 +1,140 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one bar series per result row, plus uPlot bands for stacking when
* `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(result.length + 1));
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
const stepInterval = series.queryName
? stepIntervals?.[series.queryName]
: undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: series.metric,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,9 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -0,0 +1,126 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, data, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
payload: data?.payload,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
data?.payload,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -0,0 +1,142 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
apiResponse,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,148 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
payload: MetricRangePayloadProps | undefined;
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
payload,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
if (!payload) {
return [[]];
}
const result = payload.data.result;
const values = extractNumericValues(result);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(result, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
function extractNumericValues(
result: MetricRangePayloadProps['data']['result'],
): number[] {
const values: number[] = [];
for (const item of result) {
for (const [, valueStr] of item.values) {
values.push(Number.parseFloat(valueStr) || 0);
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
result: MetricRangePayloadProps['data']['result'],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = result.map((item) =>
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -0,0 +1,6 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { preparePieData } from './prepareData';
function PiePanelRenderer({
panelId,
panel,
data,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const slices = useMemo(
() =>
preparePieData({
payload: data?.payload,
customColors: spec.legend?.customColors,
isDarkMode,
}),
[data?.payload, spec.legend?.customColors, isDarkMode],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick],
);
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
<Pie
data={slices}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
</div>
);
}
export default PiePanelRenderer;

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,89 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface PreparePieDataArgs {
payload: MetricRangePayloadProps | undefined;
/** Per-label colour overrides from `spec.legend.customColors`. */
customColors?: Record<string, string> | null;
isDarkMode: boolean;
}
// Local view of the scalar/table response. A pie issues a TABLE request (see
// `getGraphType`), which the API returns web-formatted: one aggregation column
// holds the value, the remaining (group) columns form the label, and each row's
// `data` is keyed by `column.id || column.name`. The generated `QueryData.table`
// types its columns loosely (no `isValueColumn`), so we cast to this precise
// shape once at the boundary rather than threading `any` through.
interface ScalarTableColumn {
name: string;
id?: string;
isValueColumn: boolean;
}
interface ScalarTableEntry {
queryName?: string;
legend?: string;
table?: {
columns: ScalarTableColumn[];
rows: { data: Record<string, string | number | null> }[];
};
}
/**
* Turns a query response into pie slices: one slice per group row.
*
* The reduced value-per-series lives in the scalar `table`, not the time-series
* `result[].values`. Because the pie's graphType is `TABLE`, the response is
* web-formatted and the table sits on `result[]`; we fall back to `newResult`
* for the non-`formatForWeb` shape. Labels come from the group column(s),
* colours honour `customColors` then fall back to a deterministic palette
* colour, and non-positive / non-numeric values are dropped.
*/
export function preparePieData({
payload,
customColors,
isDarkMode,
}: PreparePieDataArgs): PieSlice[] {
const primary = (payload?.data?.result ?? []) as unknown as ScalarTableEntry[];
const fallback = (payload?.data?.newResult?.data?.result ??
[]) as unknown as ScalarTableEntry[];
const entries = primary.some((entry) => entry.table) ? primary : fallback;
const colorMap = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const slices: PieSlice[] = [];
entries.forEach((entry) => {
const { table } = entry;
if (!table) {
return;
}
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.join(', ') ||
entry.legend ||
entry.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});
return slices.filter(
(slice) => Number.isFinite(slice.value) && slice.value > 0,
);
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
];

View File

@@ -0,0 +1,154 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import { useTimeScale } from '../../hooks/useTimeScale';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default TimeSeriesPanelRenderer;

View File

@@ -0,0 +1,163 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
resolveSpanGaps,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
export function buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one uPlot series per result row to the scaffolded builder. The visual
* resolution (line style, interpolation, fill mode, span gaps) reads from
* `spec.chartAppearance`; the label is resolved via the legend matrix in
* `resolveSeriesLabel`. Mutates the builder in place.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
if (!apiResponse?.payload?.data?.result) {
return;
}
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = chartAppearance?.fillMode
? FILL_MODE_MAP[chartAppearance.fillMode]
: FillMode.None;
apiResponse.payload.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
isDarkMode,
metric: series.metric,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,15 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'formatting',
controls: {
unit: true,
decimals: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -0,0 +1,9 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,59 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
*/
export interface PanelInteractionMap {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
}
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
}

View File

@@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import { DataSource } from 'types/common/queryBuilder';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}

View File

@@ -0,0 +1,20 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};

View File

@@ -0,0 +1,74 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelInteractionMap } from './interactions';
import type { PanelKind } from './panelKind';
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks. All fields are optional — non-
* dashboard render contexts (PanelEditor preview, standalone view) can pass
* an empty object and the renderer will fall back to sensible defaults.
*/
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
*/
syncMode?: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
*/
panel: DashboardtypesPanelDTO | undefined;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map to cover every PanelKind. The default K = PanelKind
// yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];

View File

@@ -0,0 +1,55 @@
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
export type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];

View File

@@ -0,0 +1,208 @@
import type {
DashboardtypesPanelFormattingDTO,
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
isLogScale?: boolean;
softMin?: number;
softMax?: number;
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
formatting?: DashboardtypesPanelFormattingDTO;
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
/** Query-range response — used by the click plugin to map pixel → data. */
apiResponse: MetricQueryRangeSuccessResponse | undefined;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
minTimeScale?: number;
maxTimeScale?: number;
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
*/
export function buildBaseConfig({
panelId,
panelType,
isDarkMode,
timezone,
panelMode,
isLogScale,
softMin,
softMax,
formatting,
thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildBaseConfigArgs): UPlotConfigBuilder {
const yAxisUnit = formatting?.unit;
const builder = new UPlotConfigBuilder({
id: panelId,
onDragSelect,
tzDate: makeTzDate(timezone),
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
stepInterval: resolveStepInterval(apiResponse),
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: mapThresholds(thresholds),
yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin,
softMax,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(
onClickPlugin({ onClick, apiResponse: apiResponse?.payload }),
);
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;
}
function makeTzDate(
timezone: Timezone,
): ((timestamp: number) => Date) | undefined {
if (!timezone) {
return undefined;
}
return (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {
return panelMode === PanelMode.DASHBOARD_VIEW ||
panelMode === PanelMode.STANDALONE_VIEW
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY;
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((t) => ({
thresholdValue: t.value,
thresholdColor: t.color,
thresholdUnit: t.unit,
thresholdLabel: t.label,
}));
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
* fall back to `undefined` (uPlot's "auto") in that case.
*/
export function resolveStepInterval(
apiResponse: MetricQueryRangeSuccessResponse | undefined,
): number | undefined {
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
if (!stepIntervals) {
return undefined;
}
const values = Object.values(stepIntervals);
if (values.length === 0) {
return undefined;
}
const min = Math.min(...values);
return Number.isFinite(min) ? min : undefined;
}

View File

@@ -0,0 +1,108 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPrecisionOptionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
};
export const LINE_INTERPOLATION_MAP: Record<
DashboardtypesLineInterpolationDTO,
LineInterpolation
> = {
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
};
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition
> = {
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
): PrecisionOption | undefined {
if (!precision) {
return undefined;
}
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return PrecisionOptionsEnum.FULL;
}
const parsed = Number(precision);
if (
parsed === 0 ||
parsed === 1 ||
parsed === 2 ||
parsed === 3 ||
parsed === 4
) {
return parsed;
}
return undefined;
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
): boolean | number {
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {
if (position && position in LEGEND_POSITION_MAP) {
return LEGEND_POSITION_MAP[position];
}
return LegendPosition.BOTTOM;
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | undefined,
): BuilderQuery[] {
if (!queries) {
return [];
}
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope?.spec?.plugin;
if (!plugin) {
return;
}
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries ?? []).forEach((sub) => {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});
}
});
return flattened;
}

View File

@@ -0,0 +1,112 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { QueryData } from 'types/api/widgets/getQuery';
/**
* Resolves the display label for one series, applying the V1 legend matrix:
* `single-vs-many builder queries × with/without groupBy × single-vs-many
* aggregations`. Returns `baseLabel` unchanged for panels without builder
* queries (PromQL, ClickHouseSQL) and for builder series whose aggregation
* carries no alias/expression — metric aggregations don't have those fields,
* so they naturally short-circuit to the base label here.
*/
export function resolveSeriesLabel(
series: QueryData,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
if (builderQueries.length === 0) {
return baseLabel;
}
const matching = builderQueries.find((q) => q.name === series.queryName);
if (!matching) {
return baseLabel;
}
// `series.metaData.index` points to the aggregation in the matched query
// that produced this series. Default to 0 so single-aggregation panels
// still resolve when the backend omits the field.
const aggIndex = series.metaData?.index ?? 0;
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only —
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
aggregation && 'expression' in aggregation
? (aggregation.expression ?? '')
: '';
if (!aggregationAlias && !aggregationExpression) {
return baseLabel || series.metaData?.queryName || matching.name || '';
}
const ctx: FormatContext = {
aggregationAlias,
aggregationExpression,
baseLabel,
legend: matching.legend ?? '',
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
singleAggregation: aggregations.length === 1,
};
return builderQueries.length === 1
? formatForSinglePanelQuery(ctx)
: formatForMultiplePanelQueries(ctx);
}
interface FormatContext {
aggregationAlias: string;
aggregationExpression: string;
baseLabel: string;
legend: string;
hasGroupBy: boolean;
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
baseLabel,
legend,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
}
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,
baseLabel,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || baseLabel || aggregationExpression;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}

View File

@@ -36,6 +36,14 @@
margin-inline-end: 0;
}
// Actions sit inside the drag-handle row but opt out of dragging
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
.actions {
display: flex;
align-items: center;
cursor: default;
}
.body {
flex: 1;
display: flex;
@@ -50,3 +58,41 @@
.bodyKind {
margin-bottom: 6px;
}
// Container for the rendered chart — fills the panel below the header and lets
// the chart shrink (min-* 0) so it resizes with the grid cell.
.chartBody {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
}
// Subtle background-refetch spinner in the header (chart stays mounted).
.refetchIndicator {
color: var(--l2-foreground);
flex-shrink: 0;
}
// Error state — shown only when there's no stale data to fall back to.
.error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
text-align: center;
}
.errorIcon {
color: var(--bg-cherry-500, #e5484d);
}
.errorMessage {
color: var(--l2-foreground);
font-size: 12px;
max-width: 90%;
overflow-wrap: anywhere;
}

View File

@@ -1,15 +1,15 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
@@ -23,16 +23,18 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
/** True once this panel's section enters the viewport — gates the fetch. */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
* `usePanelInteractions`, and the loading/error/chart state machine in
* `PanelBody`.
*/
function Panel({
panel,
panelId,
@@ -41,9 +43,22 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const panelDef = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDef && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
@@ -60,35 +75,26 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
<PanelHeader
title={headerTitle}
kind={kind}
panelId={panelId}
isFetching={isFetching}
panelActions={panelActions}
/>
<PanelBody
panelDef={panelDef}
panel={panel}
panelId={panelId}
kind={kind}
queryCount={queryCount}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
/>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import styles from './Panel.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
panelDef: RenderablePanelDefinition | undefined;
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
kind: string;
queryCount: number;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
}
/**
* Renders the panel content as an explicit state machine so each state is
* handled deliberately (no implicit fall-through):
*
* unknown-kind → unsupported fallback
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
*/
function PanelBody({
panelDef,
panel,
panelId,
kind,
queryCount,
data,
isLoading,
error,
refetch,
onDragSelect,
dashboardPreference,
}: PanelBodyProps): JSX.Element {
if (!panelDef) {
return (
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
in V2
</div>
</div>
</div>
);
}
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
if (error && !data) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
);
}
// First load only — background refetches keep `data` populated so the chart
// stays mounted instead of blinking.
if (isLoading && !data) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
return (
<div className={styles.chartBody}>
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
/>
</div>
);
}
export default PanelBody;

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical, Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface PanelHeaderProps {
title: ReactNode;
kind: string;
panelId: string;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/** Panel chrome: drag handle, title, kind badge, refetch indicator, actions. */
function PanelHeader({
title,
kind,
panelId,
isFetching,
panelActions,
}: PanelHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
{isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
data-testid="panel-refetching"
/>
)}
</div>
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
</div>
);
}
export default PanelHeader;

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { useLocation, useParams } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { dashboardId } = useParams<{ dashboardId: string }>();
const [syncMode] = useDashboardCursorSyncMode(
dashboardId,
PanelMode.DASHBOARD_VIEW,
);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
const dashboardPreference = useMemo<DashboardPreference>(
() => ({ syncMode, syncFilterMode, dashboardId }),
[syncMode, syncFilterMode, dashboardId],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
safeNavigate(`${pathname}?${urlQuery.toString()}`);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };
}

View File

@@ -54,6 +54,7 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}

View File

@@ -0,0 +1,288 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { usePanelQuery } from '../../hooks/usePanelQuery';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockUseGetQueryRange = useGetQueryRange as unknown as jest.Mock;
// ---- helpers ---------------------------------------------------------------
// Test fixtures are cast at the outer boundary; the perses-generated panel and
// query plugin unions are too verbose to construct field-typed inline.
function builderPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function listPanelWithLogs(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ListPanel', spec: {} },
queries: [
{
kind: 'LogQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function emptyPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
function histogramPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/HistogramPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function barPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/BarChartPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const DEFAULT_GLOBAL_TIME = {
selectedTime: 'GLOBAL_TIME',
minTime: 1000,
maxTime: 2000,
isAutoRefreshDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: unknown) => {
// usePanelQuery passes a selector `(state) => state.globalTime`.
return (
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
)({ globalTime: DEFAULT_GLOBAL_TIME });
});
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
});
});
// ---- tests -----------------------------------------------------------------
describe('usePanelQuery', () => {
it('runs fromPerses on the panel queries and forwards the V1 Query to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toHaveLength(1);
expect(requestArg.query.builder.queryData[0].queryName).toBe('A');
});
it('derives graphType=TIME_SERIES for a TimeSeries panel', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('derives graphType=LIST for a ListPanel', () => {
renderHook(() =>
usePanelQuery({ panel: listPanelWithLogs(), panelId: 'p1' }),
);
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.LIST);
});
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (matches V1's
// `getGraphType` translation). Without this remap, V5's
// `mapPanelTypeToRequestType` would send `distribution` for histograms,
// returning a shape `prepareHistogramData` can't bin.
it('remaps graphType=HISTOGRAM to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: histogramPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('remaps graphType=BAR to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: barPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('passes V5 entity version to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [, version] = mockUseGetQueryRange.mock.calls[0];
expect(version).toBe(ENTITY_VERSION_V5);
});
it('exposes data/error from useGetQueryRange', () => {
mockUseGetQueryRange.mockReturnValue({
data: { payload: 'X' } as unknown,
isLoading: false,
isFetching: false,
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data).toStrictEqual({ payload: 'X' });
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
it('coerces a missing/undefined error to null', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to useGetQueryRange when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
DEFAULT_GLOBAL_TIME.minTime,
DEFAULT_GLOBAL_TIME.maxTime,
DEFAULT_GLOBAL_TIME.selectedTime,
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
});
it('forwards the inflated empty composite to useGetQueryRange when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,133 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryAdapter/fromPerses';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { AppState } from 'store/reducers';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../Panels/types/panelKind';
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
* no plugin is registered for the panel's kind so the unknown-kind fallback
* UI doesn't trigger a wasted API call.
*
* The hook *also* auto-disables internally when the panel has no queries —
* callers don't need to compute that themselves.
*/
enabled?: boolean;
}
export interface UsePanelQueryResult {
data: MetricQueryRangeSuccessResponse | undefined;
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
isLoading: boolean;
/** Background refresh in flight while data is already present. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
}
/**
* Fetches the query-range data for a V2 panel.
*
* Encapsulates three concerns the dashboard shell otherwise has to wire by
* hand at every call site:
*
* 1. Adapter — runs `fromPerses` on the panel's perses queries to produce
* the V1 in-memory Query shape `useGetQueryRange` still consumes
* internally. This is a fetch-time detail; the V1 Query is not surfaced
* to callers — renderers that need it derive it themselves from
* `panel.spec.queries` (single source of truth).
* 2. Time + variables — reads the global time selection from Redux
* (variables substitution is intentionally deferred until V2 has its
* own variable plumbing).
* 3. Fetch — calls `useGetQueryRange` with the v5 entity version and a
* react-query cache key composed from panel identity + time range +
* kind + queries so cache invalidation matches the inputs that affect
* the result.
*
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
* consumed by PanelEditor (1.8) for "preview as you edit."
*/
export function usePanelQuery({
panel,
panelId,
enabled = true,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel?.spec?.plugin?.kind;
// HISTOGRAM and BAR panels both bin/derive from raw time-series data
// client-side, so the backend `requestType` for them is `time_series`.
// `getGraphType` encodes the V1-established mapping — using it keeps
// V2 in lockstep with how the API has always been called.
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
const graphType = getGraphType(panelType);
const query = useMemo(
() => fromPerses(panel?.spec?.queries ?? []),
[panel?.spec?.queries],
);
const {
selectedTime: globalSelectedInterval,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const hasQuery = useMemo(() => {
return (
query.builder.queryData.length > 0 ||
query.promql.length > 0 ||
query.clickhouse_sql.length > 0
);
}, [query]);
const response = useGetQueryRange(
{
query,
graphType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
},
ENTITY_VERSION_V5,
{
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
panelId,
minTime,
maxTime,
globalSelectedInterval,
fullKind,
panel?.spec?.queries,
],
enabled: enabled && hasQuery,
},
);
return {
data: response.data,
isLoading: response.isLoading || response.isFetching,
isFetching: response.isFetching,
// Coerce undefined → null so the contract is `Error | null`, not
// `Error | null | undefined`. Consumers can rely on a single
// "no error" sentinel.
error: (response.error as Error | null) ?? null,
refetch: response.refetch,
};
}

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const { add, replace, remove } = DashboardtypesPatchOpDTO;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -0,0 +1,741 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
type DashboardtypesQueryPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
const EMPTY_INFLATED = {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
function persesBuilderDTO(
name = 'A',
signal: 'metrics' | 'logs' | 'traces' = 'metrics',
extra: Record<string, unknown> = {},
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name,
signal,
disabled: false,
filter: { expression: '' },
...extra,
},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
function extractPlugin(dto: DashboardtypesQueryDTO): {
kind: string;
spec: Record<string, unknown>;
} {
const plugin = dto.spec?.plugin;
if (!plugin) {
throw new Error('missing plugin on perses DTO');
}
return plugin as unknown as { kind: string; spec: Record<string, unknown> };
}
// ---- Phase 1: empty / unknown contract -------------------------------------
describe('fromPerses (Phase 1 — empty / unknown-input contract)', () => {
it('returns the fully-inflated empty composite on empty input', () => {
expect(fromPerses([])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite when plugin is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite on unknown plugin kind', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/NotAThing',
spec: {},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('fromPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('unwraps a bare BuilderQuery into a single inflated queryData entry', () => {
const result = fromPerses([persesBuilderDTO('A', 'metrics')]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
const q = result.builder.queryData[0];
expect(q.queryName).toBe('A');
expect(q.expression).toBe('A');
});
it('maps signal "logs" to DataSource.LOGS', () => {
const q = fromPerses([persesBuilderDTO('A', 'logs')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.LOGS);
});
it('maps signal "traces" to DataSource.TRACES', () => {
const q = fromPerses([persesBuilderDTO('A', 'traces')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.TRACES);
});
it('maps signal "metrics" to DataSource.METRICS', () => {
const q = fromPerses([persesBuilderDTO('A', 'metrics')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
});
it('applies inverse groupBy renames (name->key, fieldDataType->dataType, fieldContext->type)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
groupBy: [
{
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
},
],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.groupBy[0]).toMatchObject({
key: 'service.name',
dataType: 'string',
type: 'tag',
});
});
it('applies inverse orderBy renames (key.name->columnName, direction->order)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.orderBy).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
});
it('preserves filter.expression', () => {
const dto = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.filter).toStrictEqual({ expression: 'service.name = "api"' });
});
it('coerces v5 stepInterval string (e.g. "30s") to null without crashing', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: '30s' });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBeNull();
});
it('keeps v5 stepInterval as-is when it is already a number', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: 60 });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBe(60);
});
});
// ---- Phase 2: round-trip ---------------------------------------------------
describe('round-trip (Phase 2 — bare BuilderQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a metrics builder with filter + groupBy + order', () => {
const original = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
groupBy: [
{ name: 'service.name', fieldDataType: 'string', fieldContext: 'tag' },
],
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const origPlugin = extractPlugin(original);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe(origPlugin.kind);
expect(rtPlugin.spec).toMatchObject({
name: 'A',
signal: 'metrics',
filter: { expression: 'service.name = "api"' },
groupBy: origPlugin.spec.groupBy,
order: origPlugin.spec.order,
});
});
it('preserves a logs builder routed to a LIST panel (outer kind raw)', () => {
const original = persesBuilderDTO('A', 'logs', {
aggregations: [{ expression: 'count()' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.LIST,
});
expect(roundTripped[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/BuilderQuery');
expect(rtPlugin.spec).toMatchObject({ name: 'A', signal: 'logs' });
});
});
// ---- Phase 3: CompositeQuery distribution ----------------------------------
function compositeDTO(
subqueries: Array<{ type: string; spec: Record<string, unknown> }>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: subqueries },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('distributes multiple builder_query subqueries into builder.queryData', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual([
'A',
'B',
]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
});
it('handles an empty CompositeQuery (queries: []) by returning the inflated empty shape', () => {
const dto = compositeDTO([]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toStrictEqual([]);
});
});
// ---- Phase 3: round-trip ---------------------------------------------------
describe('round-trip (Phase 3 — multi-builder CompositeQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a two-builder CompositeQuery (same outer kind, same subquery names)', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries.map((s) => `${s.type}:${s.spec.name}`)).toStrictEqual([
'builder_query:A',
'builder_query:B',
]);
});
});
// ---- Phase 4: bare Formula + TraceOperator + composite subqueries ----------
function bareDTO(
pluginKind: string,
spec: Record<string, unknown>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: pluginKind,
spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 4 — invalid bare Formula / TraceOperator are warned and dropped)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns and returns inflated empty composite on top-level signoz/Formula (invalid alone)', () => {
const dto = bareDTO('signoz/Formula', {
name: 'F1',
expression: 'A + 1',
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/Formula is invalid/),
);
});
it('warns and returns inflated empty composite on top-level signoz/TraceOperator (invalid alone)', () => {
const dto = bareDTO('signoz/TraceOperator', {
name: 'T1',
signal: 'traces',
expression: 'A && B',
filter: { expression: '' },
});
const result = fromPerses([dto]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/TraceOperator is invalid/),
);
});
});
describe('fromPerses (Phase 4 — composite subquery distribution)', () => {
it('distributes builder_query + builder_formula + builder_trace_operator subqueries into their respective buckets', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
},
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas.map((f) => f.queryName)).toStrictEqual([
'F1',
]);
expect(
result.builder.queryTraceOperator.map((t) => t.queryName),
).toStrictEqual(['T1']);
});
});
// ---- Phase 4: round-trips --------------------------------------------------
describe('round-trip (Phase 4): mixed composite', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
(console.warn as jest.Mock).mockRestore?.();
});
it('round-trips a CompositeQuery containing builder + formula + trace operator', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
disabled: false,
},
},
{
type: 'builder_formula',
spec: { name: 'F1', expression: 'A + 1', disabled: false },
},
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
disabled: false,
aggregations: [{ expression: 'count()' }],
},
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(
subqueries.map((s) => `${s.type}:${s.spec.name}`).sort(),
).toStrictEqual(
[
'builder_query:A',
'builder_formula:F1',
'builder_trace_operator:T1',
].sort(),
);
});
});
// ---- Phase 5: PromQL + ClickHouseSQL + composite queryType resolution ------
describe('fromPerses (Phase 5 — bare signoz/PromQLQuery)', () => {
it('unwraps bare PromQL into promql[0] and sets queryType=PROM', () => {
const dto = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql).toStrictEqual([
{ name: 'P1', query: 'up', disabled: false, legend: 'L' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
});
it('defaults disabled to false and legend to empty when absent on wire', () => {
const dto = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([dto]);
expect(result.promql[0]).toStrictEqual({
name: 'P1',
query: 'up',
disabled: false,
legend: '',
});
});
});
describe('fromPerses (Phase 5 — bare signoz/ClickHouseSQL)', () => {
it('unwraps bare ClickHouseSQL into clickhouse_sql[0] and sets queryType=CLICKHOUSE', () => {
const dto = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: '',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql).toStrictEqual([
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
});
});
describe('fromPerses (Phase 4 — composite without builder warns about invalid state)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns when CompositeQuery has formulas/trace-ops but no builder_query subquery', () => {
const dto = compositeDTO([
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/CompositeQuery contains formulas\/trace-operators but no builder_query/,
),
);
});
it('does not warn when CompositeQuery has at least one builder_query alongside the formula', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('fromPerses (Phase 5 — composite queryType resolution)', () => {
it('resolves all-promql composite to queryType=PROM', () => {
const dto = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql.map((p) => p.name)).toStrictEqual(['P1', 'P2']);
});
it('resolves all-clickhouse composite to queryType=CLICKHOUSE', () => {
const dto = compositeDTO([
{ type: 'clickhouse_sql', spec: { name: 'C1', query: 'SELECT 1' } },
{ type: 'clickhouse_sql', spec: { name: 'C2', query: 'SELECT 2' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql.map((c) => c.name)).toStrictEqual(['C1', 'C2']);
});
it('falls back to queryType=QUERY_BUILDER for a mixed-type composite', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.promql).toHaveLength(1);
});
});
// ---- Phase 5: round-trips --------------------------------------------------
describe('round-trip (Phase 5): PromQL / ClickHouseSQL bare + multi', () => {
it('round-trips bare signoz/PromQLQuery', () => {
const original = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/PromQLQuery');
expect(rtPlugin.spec).toMatchObject({
name: 'P1',
query: 'up',
legend: 'L',
});
});
it('round-trips bare signoz/ClickHouseSQL', () => {
const original = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/ClickHouseSQL');
expect(rtPlugin.spec).toMatchObject({
name: 'C1',
query: 'SELECT 1',
legend: 'L',
});
});
it('round-trips an all-promql CompositeQuery (preserves PROM queryType)', () => {
const original = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const v1 = fromPerses([original]);
expect(v1.queryType).toBe(EQueryType.PROM);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('fromPerses (Phase 6 — edge cases)', () => {
it('returns inflated empty composite when plugin is present but spec is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
// spec deliberately omitted
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('handles CompositeQuery with queries: null without crashing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: null },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder).toStrictEqual({
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
});
});
it('silently ignores composite subqueries with unrecognized types', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'future_kind' as never, spec: { foo: 'bar' } },
]);
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
});
it('skips composite subqueries that have no spec without crashing', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_formula',
spec: undefined as unknown as Record<string, unknown>,
},
]);
expect(() => fromPerses([dto])).not.toThrow();
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas).toStrictEqual([]);
});
it('only consumes the first persesQueries entry (defensive against backend invariant violations)', () => {
const builderDTO = bareDTO('signoz/BuilderQuery', {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
});
const promDTO = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([builderDTO, promDTO]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.promql).toStrictEqual([]);
});
it('produces a sensible default V1 builder when the v5 spec is minimal (signal only)', () => {
const dto = bareDTO('signoz/BuilderQuery', { signal: 'metrics' });
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
const q = result.builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
// name is generated from the default builder when v5 spec lacks one
expect(typeof q.queryName).toBe('string');
expect(q.queryName.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,186 @@
/**
* Fixture-driven round-trip suite. Loads the canonical perses dashboard testdata
* the Go backend uses to validate its serialization, then for each panel runs:
*
* fromPerses(panel.queries) → V1 Query → toPerses(query, graphType) → DTO
*
* and asserts the structural fields that should survive the round trip. This
* covers real-world panel/query combinations the synthetic unit tests miss.
*/
import fs from 'fs';
import path from 'path';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
const TESTDATA_PATH = path.join(
__dirname,
'../../../../../../../pkg/types/dashboardtypes/testdata/perses.json',
);
interface PersesPanel {
spec?: {
plugin?: { kind?: string };
queries?: DashboardtypesQueryDTO[];
};
}
interface PersesDashboardSpec {
panels?: Record<string, PersesPanel | null>;
}
interface FixturePanel {
panelId: string;
panelKind: string;
innerKind: string;
query: DashboardtypesQueryDTO;
}
function loadFixturePanels(): FixturePanel[] {
const raw = fs.readFileSync(TESTDATA_PATH, 'utf8');
const data = JSON.parse(raw) as PersesDashboardSpec;
const panels = data.panels ?? {};
return Object.entries(panels).flatMap(([panelId, panel]) => {
const panelKind = panel?.spec?.plugin?.kind;
const query = panel?.spec?.queries?.[0];
const innerKind = (query?.spec?.plugin as { kind?: string } | undefined)
?.kind;
if (!panelKind || !query || !innerKind) {
return [];
}
return [{ panelId, panelKind, innerKind, query }];
});
}
describe('round-trip: perses.json testdata → fromPerses → toPerses', () => {
const fixturePanels = loadFixturePanels();
it('testdata loaded with expected coverage', () => {
expect(fixturePanels.length).toBeGreaterThan(0);
// Sanity: at least TimeSeriesPanel + BuilderQuery is present in the testdata.
expect(
fixturePanels.some(
(p) =>
p.panelKind === 'signoz/TimeSeriesPanel' &&
p.innerKind === 'signoz/BuilderQuery',
),
).toBe(true);
});
it.each(fixturePanels)(
'panel $panelId ($panelKind / $innerKind) survives round-trip',
({ panelKind, query }) => {
const graphType =
PANEL_KIND_TO_PANEL_TYPE[panelKind as PanelKind] ?? PANEL_TYPES.TIME_SERIES;
const v1 = fromPerses([query]);
const roundTripped = toPerses({ query: v1, graphType });
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(query.kind);
const origPlugin = (query.spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
const rtPlugin = (roundTripped[0].spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
expect(rtPlugin.kind).toBe(origPlugin.kind);
const oSpec = origPlugin.spec ?? {};
const rSpec = rtPlugin.spec ?? {};
switch (origPlugin.kind) {
case 'signoz/BuilderQuery':
expectBuilderShapePreserved(oSpec, rSpec);
break;
case 'signoz/CompositeQuery':
expectCompositeShapePreserved(oSpec, rSpec);
break;
case 'signoz/PromQLQuery':
case 'signoz/ClickHouseSQL':
expectNamedQueryPreserved(oSpec, rSpec);
break;
default:
break;
}
},
);
});
// ---- assertion helpers -----------------------------------------------------
function namesOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ name?: string }>).map((g) => g.name ?? '');
}
function orderColumnsOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ key?: { name?: string } }>).map(
(o) => o.key?.name ?? '',
);
}
function expectBuilderShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.signal).toBe(original.signal);
const origFilterExpr = (original.filter as { expression?: string } | undefined)
?.expression;
const rtFilterExpr = (
roundTripped.filter as { expression?: string } | undefined
)?.expression;
expect(rtFilterExpr ?? '').toBe(origFilterExpr ?? '');
expect(namesOrEmpty(roundTripped.groupBy)).toStrictEqual(
namesOrEmpty(original.groupBy),
);
expect(orderColumnsOrEmpty(roundTripped.order)).toStrictEqual(
orderColumnsOrEmpty(original.order),
);
}
function expectCompositeShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
const origSubs =
(original.queries as Array<{ type: string }> | undefined) ?? [];
const rtSubs =
(roundTripped.queries as Array<{ type: string }> | undefined) ?? [];
expect(rtSubs).toHaveLength(origSubs.length);
expect(rtSubs.map((s) => s.type).sort()).toStrictEqual(
origSubs.map((s) => s.type).sort(),
);
}
function expectNamedQueryPreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.query).toBe(original.query);
}

View File

@@ -0,0 +1,517 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
function emptyQuery(): Query {
return {
id: 'q-empty',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
function metricsBuilderQuery(
overrides?: Partial<IBuilderQuery>,
): IBuilderQuery {
return {
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [
{
metricName: 'cpu_usage',
temporality: '',
timeAggregation: 'sum',
spaceAggregation: 'avg',
reduceTo: ReduceOperators.AVG,
},
],
timeAggregation: 'sum',
spaceAggregation: 'avg',
temporality: '',
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
expression: 'A',
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
reduceTo: ReduceOperators.AVG,
legend: '',
...overrides,
} as IBuilderQuery;
}
function builderShell(builderQueries: IBuilderQuery[]): Query {
return {
id: 'q-test',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: builderQueries,
queryFormulas: [],
queryTraceOperator: [],
},
};
}
type PluginShape = { kind: string; spec: Record<string, unknown> };
function getPlugin(result: DashboardtypesQueryDTO[]): PluginShape {
const plugin = result[0]?.spec?.plugin;
if (!plugin) {
throw new Error('toPerses returned no plugin');
}
return plugin as unknown as PluginShape;
}
// ---- Phase 1: empty contract -----------------------------------------------
describe('toPerses (Phase 1 — empty input contract)', () => {
it('returns empty array when the V1 query has no queries of any kind', () => {
const result = toPerses({
query: emptyQuery(),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('toPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('wraps a single metrics builder query as bare signoz/BuilderQuery on a TimeSeries panel', () => {
const result = toPerses({
query: builderShell([metricsBuilderQuery()]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/BuilderQuery');
expect(plugin.spec).toMatchObject({ name: 'A', signal: 'metrics' });
});
it('emits signal "logs" for a logs-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'logs' });
});
it('emits signal "traces" for a traces-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.TRACES, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'traces' });
});
it('applies groupBy field renames (key->name, dataType->fieldDataType, type->fieldContext)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
groupBy: [
{
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
id: 'service.name--string--tag--false',
},
],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const groupBy = getPlugin(result).spec.groupBy as Array<{
name: string;
fieldDataType?: string;
fieldContext?: string;
}>;
expect(groupBy[0]).toMatchObject({
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
});
});
it('applies orderBy field renames (columnName/order -> key.name/direction)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const order = getPlugin(result).spec.order as Array<{
key: { name: string };
direction: string;
}>;
expect(order[0]).toStrictEqual({
key: { name: 'timestamp' },
direction: 'desc',
});
});
it('preserves filter.expression', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
filter: { expression: 'service.name = "api"' },
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec.filter).toStrictEqual({
expression: 'service.name = "api"',
});
});
it('derives outer kind "raw" for a LIST panel', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.LIST,
});
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
expect(getPlugin(result).kind).toBe('signoz/BuilderQuery');
});
});
// ---- Phase 3: CompositeQuery wrapping --------------------------------------
describe('toPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('wraps two builder queries as signoz/CompositeQuery with two builder_query subqueries', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries).toHaveLength(2);
expect(subqueries.map((s) => s.type)).toStrictEqual([
'builder_query',
'builder_query',
]);
expect(subqueries.map((s) => s.spec.name)).toStrictEqual(['A', 'B']);
});
it('still respects backend invariant: returns exactly one envelope for any non-empty input', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
metricsBuilderQuery({ queryName: 'C' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
});
it('wraps builder + formula as signoz/CompositeQuery (mixed content)', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
const result = toPerses({
query: {
id: 'q-mixed',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).kind).toBe('signoz/CompositeQuery');
const subqueries = getPlugin(result).spec.queries as Array<{
type: string;
}>;
expect(subqueries.map((s) => s.type).sort()).toStrictEqual(
['builder_formula', 'builder_query'].sort(),
);
});
});
// ---- Phase 4: formula + trace-operator invariant guard --------------------
//
// Formulas (`A + 1`) and trace operators (`A && B`) reference builder queries
// by name. They are invalid in isolation — the wrapper must always be
// CompositeQuery alongside at least one builder query. toPerses throws on
// save-time violation; fromPerses warns on read.
describe('toPerses (Phase 4 — formula/trace-op invariant guard)', () => {
it('throws when V1 has a formula but no builder queries', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-bad-formula',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('throws when V1 has a trace operator but no builder queries', () => {
const traceOperator = {
queryName: 'T1',
dataSource: DataSource.TRACES,
expression: 'A && B',
aggregations: [{ expression: 'count()' }],
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
legend: '',
} as unknown as IBuilderQuery;
expect(() =>
toPerses({
query: {
id: 'q-bad-traceop',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [traceOperator],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('does not throw when builder is present alongside formula/trace-op', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-mixed-ok',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).not.toThrow();
});
});
// ---- Phase 5: PromQL + ClickHouseSQL ---------------------------------------
function promQuery(name: string, query = 'up'): Query {
return {
id: `q-prom-${name}`,
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
function clickhouseQuery(name: string, query = 'SELECT 1'): Query {
return {
id: `q-ch-${name}`,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
describe('toPerses (Phase 5 — PromQL)', () => {
it('wraps a single PromQL query as bare signoz/PromQLQuery', () => {
const result = toPerses({
query: promQuery('P1'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(getPlugin(result).kind).toBe('signoz/PromQLQuery');
expect(getPlugin(result).spec).toMatchObject({ name: 'P1', query: 'up' });
});
it('wraps multiple PromQL queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-prom-multi',
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [
{ name: 'P1', query: 'up', disabled: false, legend: '' },
{ name: 'P2', query: 'rate(x[1m])', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
describe('toPerses (Phase 5 — ClickHouseSQL)', () => {
it('wraps a single ClickHouse query as bare signoz/ClickHouseSQL', () => {
const result = toPerses({
query: clickhouseQuery('C1', 'SELECT count(*)'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(getPlugin(result).kind).toBe('signoz/ClickHouseSQL');
expect(getPlugin(result).spec).toMatchObject({
name: 'C1',
query: 'SELECT count(*)',
});
});
it('wraps multiple ClickHouse queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-ch-multi',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
{ name: 'C2', query: 'SELECT 2', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual([
'clickhouse_sql',
'clickhouse_sql',
]);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('toPerses (Phase 6 — edge cases)', () => {
it('returns [] when queryType is unrecognized', () => {
const result = toPerses({
query: {
id: 'q-bogus',
queryType: 'WHATEVER' as EQueryType,
promql: [{ name: 'P1', query: 'up', disabled: false, legend: '' }],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
} as unknown as Query,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,276 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderFormula,
IBuilderQuery,
IBuilderTraceOperator,
IClickHouseQuery,
IPromQLQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
BuilderQuery,
ClickHouseQuery,
CompositeQuery,
GroupByKey,
OrderBy,
PromQuery,
QueryBuilderFormula,
QueryEnvelope,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { makeEmptyV1Query, signalToDataSource } from './shared';
// IPromQLQuery and IClickHouseQuery are structurally identical
// ({name, query, disabled, legend}). Both v5 PromQuery and ClickHouseQuery
// carry the same four required-on-wire fields. One helper covers both.
type NamedQueryV5 = Pick<PromQuery, 'name' | 'query' | 'disabled' | 'legend'>;
/**
* Converts the perses on-wire envelope to the V1 in-memory `Query` shape that
* QueryBuilderContext consumes.
*
* Always returns a fully-inflated `{queryData, queryFormulas, queryTraceOperator}`
* shape so the QB UI never branches on "is the builder hydrated yet."
*
* Phase 5: all six perses plugin kinds (BuilderQuery, CompositeQuery, Formula,
* TraceOperator, PromQLQuery, ClickHouseSQL) handled both at top level and
* inside CompositeQuery. Composite queryType resolution: all-promql → PROM,
* all-clickhouse → CLICKHOUSE, otherwise QUERY_BUILDER.
*/
export function fromPerses(persesQueries: DashboardtypesQueryDTO[]): Query {
// Backend invariant: panel.queries.length === 1. We trust that here and
// only consume the first entry — extras (a malformed payload) are ignored.
const plugin = persesQueries[0]?.spec?.plugin;
const result = makeEmptyV1Query();
// Defensive: skip if plugin is missing or its spec is absent. v5BuilderToV1
// and friends assume a non-undefined spec, so a missing one would otherwise
// crash inside a helper.
if (!plugin?.spec) {
return result;
}
switch (plugin.kind) {
case 'signoz/BuilderQuery':
result.builder.queryData.push(v5BuilderToV1(plugin.spec as BuilderQuery));
break;
// Top-level Formula and TraceOperator are invalid — they reference
// builder queries by name and can only appear *inside* a CompositeQuery
// that also contains the referenced builder query. Defensive read: warn
// and drop, don't crash dashboard load.
case 'signoz/Formula':
case 'signoz/TraceOperator':
// eslint-disable-next-line no-console
console.warn(
`fromPerses: top-level ${plugin.kind} is invalid (formulas and ` +
'trace operators must travel inside a CompositeQuery alongside ' +
'the builder query they reference). Dropping.',
);
break;
case 'signoz/PromQLQuery':
result.promql.push(v5NamedQueryToV1(plugin.spec as PromQuery));
result.queryType = EQueryType.PROM;
break;
case 'signoz/ClickHouseSQL':
result.clickhouse_sql.push(v5NamedQueryToV1(plugin.spec as ClickHouseQuery));
result.queryType = EQueryType.CLICKHOUSE;
break;
case 'signoz/CompositeQuery': {
const composite = plugin.spec as CompositeQuery;
const subqueries = composite.queries ?? [];
subqueries.forEach((sub) => dispatchSubquery(sub, result));
warnIfFormulaOrTraceOperatorWithoutBuilder(subqueries);
result.queryType = resolveCompositeQueryType(subqueries);
break;
}
default:
break;
}
return result;
}
// Mirrors the toPerses save-time invariant: formulas and trace operators
// inside a CompositeQuery must be accompanied by at least one builder_query
// subquery. On read we warn rather than throw — existing (legacy or manually-
// edited) dashboards keep loading; the diagnostic surfaces the bad state.
function warnIfFormulaOrTraceOperatorWithoutBuilder(
subqueries: QueryEnvelope[],
): void {
const hasBuilder = subqueries.some((s) => s.type === 'builder_query');
if (hasBuilder) {
return;
}
const hasFormulaOrOp = subqueries.some(
(s) => s.type === 'builder_formula' || s.type === 'builder_trace_operator',
);
if (hasFormulaOrOp) {
// eslint-disable-next-line no-console
console.warn(
'fromPerses: CompositeQuery contains formulas/trace-operators but no ' +
'builder_query subquery to reference. The saved panel is in an ' +
'invalid state.',
);
}
}
// Composite queryType resolution: all-promql → PROM, all-clickhouse →
// CLICKHOUSE, otherwise QUERY_BUILDER. V1's queryType is a single value
// (the QB UI picks which sub-list is "active") so mixed-type composites
// default to QUERY_BUILDER and the QB shows the builder tab.
function resolveCompositeQueryType(subqueries: QueryEnvelope[]): EQueryType {
if (subqueries.length === 0) {
return EQueryType.QUERY_BUILDER;
}
const types = new Set(subqueries.map((s) => s.type));
if (types.size === 1 && types.has('promql')) {
return EQueryType.PROM;
}
if (types.size === 1 && types.has('clickhouse_sql')) {
return EQueryType.CLICKHOUSE;
}
return EQueryType.QUERY_BUILDER;
}
// Distributes a CompositeQuery subquery into the right V1 bucket on `result`,
// based on its v5 envelope `type`. Each phase adds another branch.
function dispatchSubquery(sub: QueryEnvelope, result: Query): void {
// Defensive: a malformed subquery without a spec is skipped — converting an
// undefined spec would crash inside a helper. The well-formed siblings in
// the same composite still flow through.
if (!sub.spec) {
return;
}
switch (sub.type) {
case 'builder_query':
result.builder.queryData.push(v5BuilderToV1(sub.spec as BuilderQuery));
break;
case 'builder_formula':
result.builder.queryFormulas.push(
v5FormulaToV1(sub.spec as QueryBuilderFormula & { disabled?: boolean }),
);
break;
case 'builder_trace_operator':
result.builder.queryTraceOperator.push(
v5TraceOperatorToV1(sub.spec as BuilderQuery),
);
break;
case 'promql':
result.promql.push(v5NamedQueryToV1(sub.spec as PromQuery));
break;
case 'clickhouse_sql':
result.clickhouse_sql.push(v5NamedQueryToV1(sub.spec as ClickHouseQuery));
break;
default:
break;
}
}
// Maps a v5 BuilderQuery (the union of Log/Metric/Trace/Meter variants) back to
// the V1 in-memory IBuilderQuery shape. Fields the v5 spec doesn't carry come
// from the signal-specific default in `initialQueryBuilderFormValuesMap`.
function v5BuilderToV1(spec: BuilderQuery): IBuilderQuery {
const dataSource = signalToDataSource(spec.signal);
const base = initialQueryBuilderFormValuesMap[dataSource];
const name = spec.name ?? base.queryName;
return {
...base,
queryName: name,
expression: name,
dataSource,
aggregations: spec.aggregations ?? base.aggregations,
filter: spec.filter ?? { expression: '' },
// V1's `filters` is the legacy V4 tag-filter input. The v5 spec doesn't
// carry it; the QB UI reconstructs items from `filter.expression` when
// parsing the saved query.
filters: { items: [], op: 'AND' },
groupBy: (spec.groupBy ?? []).map(v5GroupByToV1),
orderBy: (spec.order ?? []).map(v5OrderByToV1),
// v5 Step = string | number; V1 stepInterval = number | null. Strings
// like "30s" can't be coerced losslessly here — drop to null and let
// the UI re-derive.
stepInterval:
typeof spec.stepInterval === 'number' ? spec.stepInterval : null,
limit: spec.limit ?? null,
legend: spec.legend ?? '',
disabled: spec.disabled ?? false,
having: spec.having ?? [],
functions: spec.functions ?? [],
selectColumns: spec.selectFields,
offset: spec.offset,
// Only the MeterBuilderQuery variant has `source`; the discriminator
// check keeps TS happy and produces `''` for non-meter variants.
source: 'source' in spec ? spec.source : '',
};
}
// v5 GroupByKey uses the v5 TelemetryFieldKey field names. V1 BaseAutocompleteData
// uses the legacy {key, dataType, type} names.
function v5GroupByToV1(g: GroupByKey): BaseAutocompleteData {
return {
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
};
}
// v5 OrderBy uses {key:{name}, direction}; V1 OrderByPayload uses {columnName, order}.
function v5OrderByToV1(o: OrderBy): OrderByPayload {
return {
columnName: o.key?.name ?? '',
order: o.direction,
};
}
// v5 QueryBuilderFormula: {name, expression, functions?, order?, limit?, having?, legend?}.
// The on-wire shape also carries `disabled` (emitted by prepareQueryRangePayloadV5)
// even though the local v5 interface omits it — the intersection on the
// parameter type makes this explicit at the helper signature.
// V1 IBuilderFormula uses {queryName, expression, disabled, legend, limit, having[], orderBy}.
function v5FormulaToV1(
spec: QueryBuilderFormula & { disabled?: boolean },
): IBuilderFormula {
return {
queryName: spec.name,
expression: spec.expression,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
limit: spec.limit,
// v5 having is {expression: string}; V1 having on formula is Having[].
// They're not structurally compatible — drop to empty array.
having: [],
stepInterval: undefined,
orderBy: (spec.order ?? []).map(v5OrderByToV1),
};
}
// A v5 trace operator spec is structurally a BuilderQuery (BaseBuilderQuery
// + signal-specific aggregations) carrying the trace-operator `expression`
// field. V1 IBuilderTraceOperator is an alias for IBuilderQuery, so we delegate
// the bulk of the mapping to v5BuilderToV1 and override `expression`.
function v5TraceOperatorToV1(spec: BuilderQuery): IBuilderTraceOperator {
const base = v5BuilderToV1(spec);
return {
...base,
expression: spec.expression ?? base.expression,
};
}
// v5 PromQuery / ClickHouseQuery carry an identical {name, query, disabled?,
// legend?} core; V1 IPromQLQuery and IClickHouseQuery are also structurally
// identical {name, query, disabled, legend}. The intersection return type lets
// callers push the result into either V1 list.
function v5NamedQueryToV1(spec: NamedQueryV5): IPromQLQuery & IClickHouseQuery {
return {
name: spec.name,
query: spec.query,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
};
}

View File

@@ -0,0 +1,111 @@
import {
DashboardtypesQueryDTO,
DashboardtypesQueryPluginDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
// Outer envelope `kind` values used by the perses dashboard format. The Go
// backend defines `Query.Kind` as a free-form string, but the only values
// observed in `pkg/types/dashboardtypes/testdata` are these two.
export type PersesOuterKind = 'TimeSeriesQuery' | 'LogQuery';
// LIST panels (rows-oriented data — logs and traces alike) use LogQuery.
// Every other panel type uses TimeSeriesQuery.
export function deriveOuterKind(
graphType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
return graphType === PANEL_TYPES.LIST
? Querybuildertypesv5RequestTypeDTO.raw
: Querybuildertypesv5RequestTypeDTO.time_series;
}
// v5 builder query carries `signal` as a literal union; V1 in-memory uses the
// DataSource enum. These two helpers bridge the two sides.
export type V5Signal = 'logs' | 'metrics' | 'traces';
export function signalToDataSource(signal: V5Signal): DataSource {
if (signal === 'logs') {
return DataSource.LOGS;
}
if (signal === 'traces') {
return DataSource.TRACES;
}
return DataSource.METRICS;
}
export function dataSourceToSignal(dataSource: DataSource): V5Signal {
if (dataSource === DataSource.LOGS) {
return 'logs';
}
if (dataSource === DataSource.TRACES) {
return 'traces';
}
return 'metrics';
}
// fromPerses always returns this fully-inflated empty composite shape so the
// QB UI never has to branch on "is the builder hydrated yet."
export function makeEmptyV1Query(): Query {
return {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
// Inner plugin kinds that wrap a single v5 envelope spec (no further nesting).
// CompositeQuery is excluded — it has its own wrapper (`wrapComposite`).
export type BarePluginKind =
| 'signoz/BuilderQuery'
| 'signoz/Formula'
| 'signoz/TraceOperator'
| 'signoz/PromQLQuery'
| 'signoz/ClickHouseSQL';
// Packages a single v5 envelope spec into the perses outer DTO with the
// specified inner plugin kind. The one cast per call bridges the structurally-
// identical-but-nominally-distinct local v5 / generated perses type pair.
export function wrapBare(
outerKind: Querybuildertypesv5RequestTypeDTO,
pluginKind: BarePluginKind,
envelope: QueryEnvelope,
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: pluginKind,
spec: envelope.spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
// Packages a list of v5 envelopes into a perses `signoz/CompositeQuery` DTO.
// Used when V1 has multi-query, formula, or trace-operator content — anything
// the bare wrapper kinds can't represent.
export function wrapComposite(
outerKind: Querybuildertypesv5RequestTypeDTO,
envelopes: QueryEnvelope[],
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: envelopes },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}

View File

@@ -0,0 +1,135 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { prepareQueryRangePayloadV5 } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope, QueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import {
type BarePluginKind,
deriveOuterKind,
wrapBare,
wrapComposite,
} from './shared';
// QUERY_BUILDER single-envelope dispatch table: when the v5 forward conversion
// emits exactly one envelope, the envelope's `type` determines which bare
// perses plugin kind wraps it. Only builder_query bare-wraps — formulas and
// trace operators reference builder queries by name (e.g. "A + 1", "A && B")
// and can never exist alone, so they always travel inside a CompositeQuery
// alongside the builder query they reference.
const QUERY_BUILDER_BARE_KIND: Partial<Record<QueryType, BarePluginKind>> = {
builder_query: 'signoz/BuilderQuery',
};
export interface ToPersesArgs {
query: Query;
graphType: PANEL_TYPES;
}
/**
* Converts a V1 in-memory `Query` to the perses on-wire envelope used by V2
* dashboards.
*
* Returns `[]` for genuinely-empty input, otherwise an array of length exactly
* one (the perses backend invariant: `panel.queries.length === 1`).
*
* Phase 5: all six perses plugin kinds — BuilderQuery, Formula, TraceOperator,
* CompositeQuery, PromQLQuery, ClickHouseSQL. PROM and CLICKHOUSE queryTypes
* collapse to their bare envelopes on single-query input, composite otherwise.
*/
export function toPerses({
query,
graphType,
}: ToPersesArgs): DashboardtypesQueryDTO[] {
if (isEmptyQuery(query)) {
return [];
}
const { queryPayload } = prepareQueryRangePayloadV5({
query,
graphType,
selectedTime: 'GLOBAL_TIME',
});
const envelopes = queryPayload.compositeQuery.queries ?? [];
if (envelopes.length === 0) {
return [];
}
const outerKind = deriveOuterKind(graphType);
switch (query.queryType) {
case EQueryType.QUERY_BUILDER:
assertFormulaAndTraceOperatorReferenceBuilder(query);
return [buildQueryBuilderEnvelope(envelopes, outerKind)];
case EQueryType.PROM:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/PromQLQuery')];
case EQueryType.CLICKHOUSE:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/ClickHouseSQL')];
default:
return [];
}
}
// Semantic invariant: formulas (`A + 1`) and trace operators (`A && B`)
// reference builder queries by name. They are only meaningful when at least
// one builder query is present in the same panel. Throw on save-time
// violation so corrupt state can't be persisted; reads are handled defensively
// in fromPerses.
function assertFormulaAndTraceOperatorReferenceBuilder(query: Query): void {
const builderCount = query.builder?.queryData?.length ?? 0;
const formulaCount = query.builder?.queryFormulas?.length ?? 0;
const traceOperatorCount = query.builder?.queryTraceOperator?.length ?? 0;
if (builderCount > 0) {
return;
}
if (formulaCount === 0 && traceOperatorCount === 0) {
return;
}
throw new Error(
'toPerses: cannot serialize a query with ' +
`${formulaCount} formula(s) and ${traceOperatorCount} trace operator(s) ` +
'but no builder queries. Formulas and trace operators reference ' +
'builder queries by name and cannot exist alone.',
);
}
// QUERY_BUILDER dispatch: a single envelope of a recognized type collapses to
// its bare wrapper; anything else (multi-envelope, mixed types) becomes a
// CompositeQuery containing all envelopes.
function buildQueryBuilderEnvelope(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
): DashboardtypesQueryDTO {
if (envelopes.length === 1) {
const bareKind = QUERY_BUILDER_BARE_KIND[envelopes[0].type];
if (bareKind) {
return wrapBare(outerKind, bareKind, envelopes[0]);
}
}
return wrapComposite(outerKind, envelopes);
}
// PROM / CLICKHOUSE dispatch: single envelope → bare; multi → composite.
function buildBareOrComposite(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
bareKind: BarePluginKind,
): DashboardtypesQueryDTO {
return envelopes.length === 1
? wrapBare(outerKind, bareKind, envelopes[0])
: wrapComposite(outerKind, envelopes);
}
function isEmptyQuery(query: Query): boolean {
const hasBuilder =
(query.builder?.queryData?.length ?? 0) > 0 ||
(query.builder?.queryFormulas?.length ?? 0) > 0 ||
(query.builder?.queryTraceOperator?.length ?? 0) > 0;
const hasPromql = (query.promql?.length ?? 0) > 0;
const hasClickhouse = (query.clickhouse_sql?.length ?? 0) > 0;
return !hasBuilder && !hasPromql && !hasClickhouse;
}

View File

@@ -41,6 +41,10 @@ import SearchBar from '../SearchBar/SearchBar';
import DashboardsListContent from './DashboardsListContent';
import styles from './DashboardsList.module.scss';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
const PAGE_SIZE = 20;
@@ -82,8 +86,8 @@ function DashboardsList(): JSX.Element {
const listParams = useMemo(
() => ({
query: searchString.trim() || undefined,
sort: sortColumn,
order: sortOrder,
sort: sortColumn as DashboardtypesListSortDTO,
order: sortOrder as DashboardtypesListOrderDTO,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export type DashboardListItem = DashboardtypesGettableDashboardV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
export default function NozButton(): JSX.Element | null {
const { pathname } = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const handleOpenNoz = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.TraceDetails,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled) {
return null;
}
return (
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="ghost"
size="icon"
color="secondary"
className="noz-wave"
aria-label="Open Noz"
onClick={handleOpenNoz}
>
<Noz size={16} />
</Button>
</TooltipSimple>
);
}

View File

@@ -20,6 +20,7 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -426,6 +427,8 @@ function Filters({
)}
</div>
<NozButton />
<div className={styles.highlightControl}>{highlightErrorsToggle}</div>
</div>
</TooltipProvider>

View File

@@ -48,9 +48,7 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
"orval.config.ts"
],
"include": [
"./src",

View File

@@ -95,7 +95,7 @@ export default defineConfig(({ mode }): UserConfig => {
project: env.VITE_SENTRY_PROJECT_ID,
// Pin the sourcemap-upload release to the same value injected as
// process.env.VERSION so uploaded sourcemaps resolve. Ref: platform-pod#2393
release: { name: env.VITE_VERSION },
release: { name: env.VITE_VERSION, setCommits: { auto: true } },
}),
);
}

View File

@@ -0,0 +1 @@
<svg id="b70acf0a-34b4-4bdf-9024-7496043ff915" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="e2cf8746-c9a8-4eee-86c2-4951983c6032" cx="13428.81" cy="3518.86" r="56.67" gradientTransform="translate(-2005.33 -518.83) scale(0.15)" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><linearGradient id="bdd213dd-d313-473c-8ff4-0133fd3a9033" x1="4.4" y1="11.48" x2="4.37" y2="7.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="afcc63c5-3649-4476-a742-bcb53a569f3c" x1="10.13" y1="15.45" x2="10.13" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="bd873f0b-9954-4aa5-a3df-9f4c64e8729d" x1="14.18" y1="11.15" x2="14.18" y2="7.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient></defs><title>Icon-web-41</title><path id="ee75dd06-1aca-4f76-9d11-d05a284020ad" d="M14.21,15.72A8.5,8.5,0,0,1,3.79,2.28l.09-.06a8.5,8.5,0,0,1,10.33,13.5" fill="url(#e2cf8746-c9a8-4eee-86c2-4951983c6032)"/><path d="M6.69,7.23A13,13,0,0,1,15.6,3.65a8.47,8.47,0,0,0-1.49-1.44,14.34,14.34,0,0,0-4.69,1.1A12.54,12.54,0,0,0,5.34,6.13,2.76,2.76,0,0,1,6.69,7.23Z" fill="#fff" opacity="0.6"/><path d="M2.48,10.65a17.86,17.86,0,0,0-.83,2.62,7.82,7.82,0,0,0,.62.92c.18.23.35.44.55.65A17.94,17.94,0,0,1,3.9,11.37,2.76,2.76,0,0,1,2.48,10.65Z" fill="#fff" opacity="0.6"/><path d="M3.46,6.11a12,12,0,0,1-.69-2.94,8.15,8.15,0,0,0-1.1,1.45A12.69,12.69,0,0,0,2.24,7,2.69,2.69,0,0,1,3.46,6.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><path d="M8.36,13.67A1.77,1.77,0,0,1,8.9,12.4a11.88,11.88,0,0,1-2.53-1.86,2.74,2.74,0,0,1-1.49.83,13.1,13.1,0,0,0,1.45,1.28A12.12,12.12,0,0,0,8.38,13.9,1.79,1.79,0,0,1,8.36,13.67Z" fill="#f2f2f2" opacity="0.55"/><path d="M14.66,13.88a12,12,0,0,1-2.76-.32.41.41,0,0,1,0,.11,1.75,1.75,0,0,1-.51,1.24,13.69,13.69,0,0,0,3.42.24A8.21,8.21,0,0,0,16,13.81,11.5,11.5,0,0,1,14.66,13.88Z" fill="#f2f2f2" opacity="0.55"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/><path d="M12.32,8.93a1.83,1.83,0,0,1,.61-1A25.5,25.5,0,0,1,8.47,3.79a16.91,16.91,0,0,1-2-2.92,7.64,7.64,0,0,0-1.09.42A18.14,18.14,0,0,0,7.53,4.47,26.44,26.44,0,0,0,12.32,8.93Z" fill="#f2f2f2" opacity="0.7"/><circle cx="14.18" cy="9.27" r="1.89" fill="url(#bd873f0b-9954-4aa5-a3df-9f4c64e8729d)"/><path d="M17.35,10.54,17,10.37l0,0-.3-.16-.06,0L16.38,10l-.07,0L16,9.8a1.76,1.76,0,0,1-.64.92c.12.08.25.15.38.22l.08.05.35.19,0,0,.86.45h0a8.63,8.63,0,0,0,.29-1.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,272 @@
{
"id": "appservice",
"title": "App Services",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_averagememoryworkingset_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytesreceived_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytessent_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_count",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_total",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_minimum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_maximum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_currentassemblies_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_filesystemusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen0collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ge10collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen2collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_handles_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_healthcheckstatus_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http101_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http2xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http3xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http401_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http403_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http404_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http406_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http4xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http5xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_httpresponsetime_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootherbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootheroperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowritebytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowriteoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_privatebytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requestsinapplicationqueue_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_thread_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomains_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomainsunloaded_average",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Web",
"resourceType": "sites",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "App Services Overview",
"description": "Overview of App Services metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,5 @@
### Monitor Azure App Services with SigNoz
Collect key App Services metrics and view them with an out of the box dashboard.
Note: This integration DO NOT collect metrics for any database that was setup with your App Service (if any).

View File

@@ -0,0 +1 @@
<svg id="fd454f1c-5506-44b8-874e-8814b8b2f70b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="f34d9569-2bd0-4002-8f16-3d01d8106cb5" x1="8.88" y1="12.21" x2="8.88" y2="0.21" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0078d4"/><stop offset="0.82" stop-color="#5ea0ef"/></linearGradient><linearGradient id="bdb45a0b-eb58-4970-a60a-fb2ce314f866" x1="8.88" y1="16.84" x2="8.88" y2="12.21" gradientUnits="userSpaceOnUse"><stop offset="0.15" stop-color="#ccc"/><stop offset="1" stop-color="#707070"/></linearGradient></defs><title>Icon-compute-21</title><rect x="-0.12" y="0.21" width="18" height="12" rx="0.6" fill="url(#f34d9569-2bd0-4002-8f16-3d01d8106cb5)"/><polygon points="11.88 4.46 11.88 7.95 8.88 9.71 8.88 6.21 11.88 4.46" fill="#50e6ff"/><polygon points="11.88 4.46 8.88 6.22 5.88 4.46 8.88 2.71 11.88 4.46" fill="#c3f1ff"/><polygon points="8.88 6.22 8.88 9.71 5.88 7.95 5.88 4.46 8.88 6.22" fill="#9cebff"/><polygon points="5.88 7.95 8.88 6.21 8.88 9.71 5.88 7.95" fill="#c3f1ff"/><polygon points="11.88 7.95 8.88 6.21 8.88 9.71 11.88 7.95" fill="#9cebff"/><path d="M12.49,15.84c-1.78-.28-1.85-1.56-1.85-3.63H7.11c0,2.07-.06,3.35-1.84,3.63a1,1,0,0,0-.89,1h9A1,1,0,0,0,12.49,15.84Z" fill="url(#bdb45a0b-eb58-4970-a60a-fb2ce314f866)"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
### Monitor Azure Virtual Machines with SigNoz
Collect key Virtual Machines metrics and view them with an out of the box dashboard.

View File

@@ -27,6 +27,8 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceVirtualMachine = ServiceID{valuer.NewString("virtualmachine")}
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
AzureServiceAKS = ServiceID{valuer.NewString("aks")}
)
@@ -48,6 +50,8 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceVirtualMachine,
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
}
@@ -73,6 +77,8 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceVirtualMachine,
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
},