Compare commits

..

14 Commits

Author SHA1 Message Date
Abhi Kumar
634a1d285a chore: fmt fix + panel fetch fix 2026-06-08 17:48:35 +05:30
Abhi Kumar
9eca33ed3d 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-08 16:57:41 +05:30
Abhi Kumar
7a1d8bd6fb 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-08 15:41:40 +05:30
Abhi Kumar
67ba89bcab 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-08 15:41:40 +05:30
Abhi Kumar
63f467fb7c 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-08 15:41:40 +05:30
Abhi Kumar
e175838678 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-08 15:41:40 +05:30
Abhi Kumar
69805d0214 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-08 15:41:40 +05:30
Abhi Kumar
c95c8d8d53 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-08 15:41:40 +05:30
Abhi Kumar
4998edde74 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-08 15:41:40 +05:30
Abhi Kumar
5d645e28f5 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-08 15:41:40 +05:30
Ashwin Bhatkal
9cd5f3e1c9 feat: dashboard configuration 2026-06-08 15:41:40 +05:30
Ashwin Bhatkal
2255334ce1 feat: dashboard configuration 2026-06-08 15:41:36 +05:30
Ashwin Bhatkal
4d7f25d7ec 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-08 15:37:46 +05:30
Ashwin Bhatkal
2e659c0407 feat(dashboards-list-v2): loading / error / empty / no-results state components 2026-06-08 15:33:17 +05:30
168 changed files with 7386 additions and 12079 deletions

View File

@@ -39,12 +39,10 @@ jobs:
matrix:
suite:
- alerts
- basepath
- callbackauthn
- cloudintegrations
- dashboard
- ingestionkeys
- inframonitoring
- logspipelines
- passwordauthn
- preference
@@ -85,7 +83,7 @@ jobs:
run: |
cd tests && uv sync
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
if: matrix.suite == 'callbackauthn'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list

View File

@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)

View File

@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
if err != nil {
return nil, err
}
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
if err != nil {
return nil, err
}
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
if err != nil {
return nil, err
}

View File

@@ -440,17 +440,6 @@ traces:
max_depth_to_auto_expand: 5
# Threshold below which all spans are returned without windowing.
max_limit_to_select_all_spans: 10000
flamegraph:
# Maximum number of BFS depth levels included in a windowed response.
max_selected_levels: 50
# Maximum spans per level before sampling is applied.
max_spans_per_level: 100
# Number of highest-latency spans always included when sampling a level.
sampling_top_latency_count: 5
# Number of timestamp buckets used for uniform sampling within a level.
sampling_bucket_count: 50
# Threshold below which all spans are returned without windowing or sampling.
select_all_spans_limit: 100000
##################### Authz #################################
authz:

View File

@@ -0,0 +1,6 @@
services:
signoz:
environment:
# Enable dashboard v2 (maps to flagger.config.boolean.use_dashboard_v2: true)
# Double underscore is the key separator; single underscore stays part of the key.
- SIGNOZ_FLAGGER__CONFIG__BOOLEAN__USE_DASHBOARD_V2=true

View File

@@ -1360,8 +1360,6 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
- aks
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -6640,70 +6638,6 @@ components:
- attribute
- resource
type: string
SpantypesFlamegraphSpan:
properties:
attributes:
additionalProperties: {}
type: object
durationNano:
minimum: 0
type: integer
event:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
hasError:
type: boolean
level:
format: int64
type: integer
name:
type: string
parentSpanId:
type: string
resource:
additionalProperties:
type: string
type: object
spanId:
type: string
timestamp:
minimum: 0
type: integer
required:
- spanId
- parentSpanId
- timestamp
- durationNano
- hasError
- name
- level
- event
- attributes
- resource
type: object
SpantypesGettableFlamegraphTrace:
properties:
endTimestampMillis:
format: int64
type: integer
hasMore:
type: boolean
spans:
items:
items:
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
type: array
type: array
startTimestampMillis:
format: int64
type: integer
required:
- spans
- startTimestampMillis
- endTimestampMillis
- hasMore
type: object
SpantypesGettableSpanMapperGroups:
properties:
items:
@@ -6724,6 +6658,11 @@ components:
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
@@ -6764,15 +6703,6 @@ components:
traceId:
type: string
type: object
SpantypesPostableFlamegraph:
properties:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
selectedSpanId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -6811,6 +6741,14 @@ components:
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
nullable: true
type: array
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
@@ -20597,11 +20535,12 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/flamegraph:
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the flamegraph view of spans for a given trace ID.
operationId: GetFlamegraph
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
parameters:
- in: path
name: traceID
@@ -20612,7 +20551,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
$ref: '#/components/schemas/SpantypesPostableWaterfall'
responses:
"200":
content:
@@ -20620,7 +20559,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
status:
type: string
required:
@@ -20663,7 +20602,7 @@ paths:
- VIEWER
- tokenizer:
- VIEWER
summary: Get flamegraph view for a trace
summary: Get waterfall view for a trace
tags:
- tracedetail
/api/v4/traces/{traceID}/waterfall:

View File

@@ -5,12 +5,10 @@ import (
"fmt"
"log/slog"
"net/url"
"path"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -28,14 +26,13 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
globalConfig global.Config
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -44,11 +41,10 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
globalConfig: globalConfig,
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
}, nil
}
@@ -201,7 +197,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
Path: redirectPath,
}).String(),
}, nil
}

View File

@@ -6,12 +6,10 @@ import (
"encoding/base64"
"encoding/pem"
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -26,16 +24,14 @@ const (
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
globalConfig global.Config
store authtypes.AuthNStore
licensing licensing.Licensing
}
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
return &AuthN{
store: store,
licensing: licensing,
globalConfig: globalConfig,
store: store,
licensing: licensing,
}, nil
}
@@ -136,7 +132,7 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
return nil, err
}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
// Note:
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.

View File

@@ -41,15 +41,6 @@ if (typeof window.IntersectionObserver === 'undefined') {
(window as any).IntersectionObserver = IntersectionObserverMock;
}
if (typeof window.ResizeObserver === 'undefined') {
class ResizeObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
(window as any).ResizeObserver = ResizeObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -641,6 +641,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)

View File

@@ -2651,8 +2651,6 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
aks = 'aks',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4683,6 +4681,61 @@ export interface DashboardtypesGettableDashboardV2DTO {
updatedBy?: string;
}
export interface DashboardtypesGettableDashboardWithPinDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned?: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesGettablePublicDasbhboardDTO {
/**
* @type string
@@ -7771,77 +7824,6 @@ export enum SpantypesFieldContextDTO {
attribute = 'attribute',
resource = 'resource',
}
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
*/
attributes: SpantypesFlamegraphSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
durationNano: number;
/**
* @type array
*/
event: SpantypesEventDTO[];
/**
* @type boolean
*/
hasError: boolean;
/**
* @type integer
* @format int64
*/
level: number;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parentSpanId: string;
/**
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
/**
* @type string
*/
spanId: string;
/**
* @type integer
* @minimum 0
*/
timestamp: number;
}
export interface SpantypesGettableFlamegraphTraceDTO {
/**
* @type integer
* @format int64
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMore: boolean;
/**
* @type array
*/
spans: SpantypesFlamegraphSpanDTO[][];
/**
* @type integer
* @format int64
*/
startTimestampMillis: number;
}
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
/**
* @type array,null
@@ -8093,6 +8075,10 @@ export interface SpantypesWaterfallSpanDTO {
}
export interface SpantypesGettableWaterfallTraceDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
@@ -8139,17 +8125,6 @@ export interface SpantypesGettableWaterfallTraceDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesPostableFlamegraphDTO {
/**
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @type string
*/
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
@@ -8212,6 +8187,15 @@ export interface SpantypesPostableTraceAggregationsDTO {
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
@@ -9647,6 +9631,42 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type string
* @description undefined
*/
sort?: string;
/**
* @type string
* @description undefined
*/
order?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -10495,11 +10515,11 @@ export type GetHosts200 = {
status: string;
};
export type GetFlamegraphPathParameters = {
export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetFlamegraph200 = {
data: SpantypesGettableFlamegraphTraceDTO;
export type GetWaterfall200 = {
data: SpantypesGettableWaterfallTraceDTO;
/**
* @type string
*/

View File

@@ -12,14 +12,13 @@ import type {
} from 'react-query';
import type {
GetFlamegraph200,
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableFlamegraphDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
@@ -128,46 +127,46 @@ export const useGetTraceAggregations = <
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace
*/
export const getFlamegraph = (
{ traceID }: GetFlamegraphPathParameters,
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFlamegraph200>({
url: `/api/v3/traces/${traceID}/flamegraph`,
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableFlamegraphDTO,
data: spantypesPostableWaterfallDTO,
signal,
});
};
export const getGetFlamegraphMutationOptions = <
export const getGetWaterfallMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
const mutationKey = ['getFlamegraph'];
const mutationKey = ['getWaterfall'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -177,54 +176,54 @@ export const getGetFlamegraphMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getFlamegraph>>,
Awaited<ReturnType<typeof getWaterfall>>,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getFlamegraph(pathParams, data);
return getWaterfall(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetFlamegraphMutationResult = NonNullable<
Awaited<ReturnType<typeof getFlamegraph>>
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetFlamegraphMutationBody =
| BodyType<SpantypesPostableFlamegraphDTO>
export type GetWaterfallMutationBody =
| BodyType<SpantypesPostableWaterfallDTO>
| undefined;
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get flamegraph view for a trace
* @summary Get waterfall view for a trace
*/
export const useGetFlamegraph = <
export const useGetWaterfall = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getFlamegraph>>,
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getFlamegraph>>,
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
return useMutation(getGetFlamegraphMutationOptions(options));
return useMutation(getGetWaterfallMutationOptions(options));
};
/**
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3

View File

@@ -27,6 +27,7 @@ const getTraceV4 = async (
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
);

View File

@@ -36,7 +36,7 @@
align-items: center;
justify-content: center;
}
.pieChartTooltipContent {
.pieTooltipContent {
display: flex;
align-items: center;
justify-content: center;
@@ -51,7 +51,7 @@
display: inline-block;
}
.pieChartTooltipValue {
.tooltipValue {
font-weight: bold;
margin-top: 4px;
}
@@ -59,7 +59,7 @@
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieChartLegend {
.pieLegend {
flex: 0 0 auto;
min-height: 0;
min-width: 0;

View File

@@ -11,13 +11,18 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { PieChartProps, PieSlice } from '../types';
import { calculateChartDimensions } from '../utils';
import { usePieInteractions } from '../../hooks/usePieInteractions';
import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { usePieInteractions } from './usePieInteractions';
import { getFillColor } from './utils';
interface PieTooltipData {
label: string;
value: string;
color: string;
}
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
@@ -66,34 +71,21 @@ export default function Pie({
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),
[visibleData],
const dimensions = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
const size = Math.min(dimensions.width, dimensions.height);
const radius = size * 0.35;
const innerRadius = radius * 0.6;
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor = active?.color ?? null;
@@ -109,12 +101,19 @@ export default function Pie({
),
color: slice.color,
},
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
tooltipTop: centroidY + dimensions.height / 2,
tooltipLeft: centroidX + dimensions.width / 2,
});
setActive(slice);
},
[showTooltip, setActive, yAxisUnit, decimalPrecision, height, width],
[
showTooltip,
setActive,
yAxisUnit,
decimalPrecision,
dimensions.height,
dimensions.width,
],
);
const handleSliceLeave = useCallback((): void => {
@@ -143,10 +142,17 @@ export default function Pie({
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
data-testid={testId}
>
<div className={styles.pieChartContainer} style={{ width, height }}>
<div
className={styles.pieChartContainer}
style={{ width: dimensions.width, height: dimensions.height }}
>
{size > 0 && (
<svg width={width} height={height} ref={containerRef}>
<Group top={height / 2} left={width / 2}>
<svg
width={dimensions.width}
height={dimensions.height}
ref={containerRef}
>
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
@@ -206,24 +212,24 @@ export default function Pie({
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
/>
<div className={styles.pieChartTooltipContent}>
<div className={styles.pieTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
<span className={styles.tooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
<div
className={styles.pieChartLegend}
className={styles.pieLegend}
style={{
width: legendWidth,
height: legendHeight,
width: dimensions.legendWidth,
height: dimensions.legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
averageLegendWidth={dimensions.averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}

View File

@@ -1,116 +0,0 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from '../../types';
import Pie from '../Pie';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
}));
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
// VirtuosoGrid only renders a window in jsdom; render every item so we can
// assert on legend entries.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#aa0000' },
{ label: 'cart', value: 60, color: '#00aa00' },
{ label: 'checkout', value: 40, color: '#0000aa' },
];
function renderPie(
props: Partial<React.ComponentProps<typeof Pie>> = {},
): void {
render(
<TooltipProvider>
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />
</TooltipProvider>,
);
}
describe('Pie', () => {
it('renders the "No data" state for empty data', () => {
render(
<TooltipProvider>
<Pie data={[]} isDarkMode={false} data-testid="pie" />
</TooltipProvider>,
);
expect(screen.getByText('No data')).toBeInTheDocument();
});
it('renders one arc per slice plus the legend entries and centre total', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(DATA.length);
const legend = screen.getByTestId('virtuoso-grid');
expect(within(legend).getByText('frontend')).toBeInTheDocument();
expect(within(legend).getByText('cart')).toBeInTheDocument();
expect(within(legend).getByText('checkout')).toBeInTheDocument();
// Centre total = 100 + 60 + 40 (formatter mocked to echo the value).
expect(screen.getByText('200')).toBeInTheDocument();
});
it('lays the legend out in a row for the right position and a column for bottom', () => {
const { rerender } = render(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.RIGHT}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'row' });
rerender(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.BOTTOM}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('hides a slice when its legend marker is clicked', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(2);
});
});

View File

@@ -1,85 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from '../../types';
import PieArc from '../PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
onEnter: jest.Mock;
onLeave: jest.Mock;
onClick: jest.Mock;
container: HTMLElement;
} {
const onEnter = jest.fn();
const onLeave = jest.fn();
const onClick = jest.fn();
const { container } = render(
<svg>
<PieArc
slice={SLICE}
arcPath="M0,0L1,1"
centroid={[10, 20]}
startAngle={0}
endAngle={Math.PI}
radius={100}
totalValue={100}
labelColor="#fff"
fill="#f00"
onEnter={onEnter}
onLeave={onLeave}
onClick={onClick}
{...props}
/>
</svg>,
);
return { onEnter, onLeave, onClick, container };
}
describe('PieArc', () => {
it('renders the arc path with the resolved fill', () => {
const { container } = renderArc();
const path = container.querySelector('path');
expect(path).toHaveAttribute('d', 'M0,0L1,1');
expect(path).toHaveAttribute('fill', '#f00');
});
it('shows the leader label + value for a slice above the threshold', () => {
renderArc(); // 50 / 100 = 0.5
expect(screen.getByText('frontend')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('hides the leader label for a slice below the 3% threshold', () => {
renderArc({ totalValue: 10000 }); // 50 / 10000 = 0.005
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
// the arc path itself still renders
expect(screen.queryByText('50')).not.toBeInTheDocument();
});
it('truncates labels longer than 15 chars', () => {
renderArc({
slice: { label: 'a-really-long-service-name', value: 50, color: '#f00' },
});
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
});
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
const { onEnter, onLeave, onClick, container } = renderArc();
const g = container.querySelector('g') as SVGGElement;
fireEvent.mouseEnter(g);
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
fireEvent.mouseLeave(g);
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
});
});

View File

@@ -1,45 +0,0 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from '../PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),
}));
const mockFormat = getYAxisFormattedValue as jest.MockedFunction<
typeof getYAxisFormattedValue
>;
function renderInSvg(node: JSX.Element): ReturnType<typeof render> {
// PieCenterLabel returns an SVG <text>, so it needs an <svg> host.
return render(<svg>{node}</svg>);
}
describe('PieCenterLabel', () => {
const baseProps = {
total: 3700,
radius: 100,
innerRadius: 60,
color: '#fff',
};
it('renders the formatted total (numeric + unit suffix) as one numeric tspan when there is no separate unit', () => {
mockFormat.mockReturnValue('3.7K');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('3.7K')).toBeInTheDocument();
});
it('splits the numeric part and the trailing unit into separate tspans', () => {
mockFormat.mockReturnValue('1.2 MB');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('1.2')).toBeInTheDocument();
expect(screen.getByText('MB')).toBeInTheDocument();
});
it('passes the unit + precision through to the formatter', () => {
mockFormat.mockReturnValue('100');
renderInSvg(<PieCenterLabel {...baseProps} total={100} yAxisUnit="bytes" />);
expect(mockFormat).toHaveBeenCalledWith('100', 'bytes', undefined);
});
});

View File

@@ -1,101 +0,0 @@
import {
getArcGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
30,
);
});
it('does not scale short text (length <= 3)', () => {
// scaleFactor = max(0.3, 1) = 1 → baseSize, capped by innerRadius * 0.9.
expect(
getScaledFontSize({ text: '3.7', baseSize: 30, innerRadius: 100 }),
).toBe(30);
});
it('scales longer text down', () => {
// length 8 → scaleFactor = max(0.3, 1 - 5 * 0.09) = 0.55 → 30 * 0.55.
expect(
getScaledFontSize({ text: '12345678', baseSize: 30, innerRadius: 100 }),
).toBeCloseTo(16.5);
});
it('floors the scale factor at 0.3 for very long text', () => {
// length 20 → 1 - 17 * 0.09 < 0.3 → floored to 0.3 → 100 * 0.3.
expect(
getScaledFontSize({
text: '12345678901234567890',
baseSize: 100,
innerRadius: 1000,
}),
).toBeCloseTo(30);
});
it('caps the size at 90% of the inner radius', () => {
expect(
getScaledFontSize({ text: '3.7', baseSize: 200, innerRadius: 10 }),
).toBeCloseTo(9);
});
});
describe('getArcGeometry', () => {
it('places the label below for a slice centred at the top (angle 0)', () => {
const g = getArcGeometry(0, 0, 100);
expect(g.labelX).toBeCloseTo(0);
expect(g.labelY).toBeCloseTo(-130);
expect(g.lineEndX).toBeCloseTo(0);
expect(g.lineEndY).toBeCloseTo(-110);
// sin(0) is not > 0 → anchor end.
expect(g.textAnchor).toBe('end');
});
it('anchors to the start on the right half (angle pi/2)', () => {
const g = getArcGeometry(0, Math.PI, 100);
expect(g.labelX).toBeCloseTo(130);
expect(g.labelY).toBeCloseTo(0);
expect(g.textAnchor).toBe('start');
});
it('anchors to the end on the left half (angle 3pi/2)', () => {
const g = getArcGeometry(Math.PI, 2 * Math.PI, 100);
expect(g.labelX).toBeCloseTo(-130);
expect(g.textAnchor).toBe('end');
});
});
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -1,35 +0,0 @@
/**
* Pie-local types. Kept out of the component / util files so each stays focused
* (per the one-component-per-file + dedicated-types rules). Shared chart types
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
*/
export interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
export interface ParsedRgb {
r: number;
g: number;
b: number;
}
/** Resolved tooltip payload shown when a slice is hovered. */
export interface PieTooltipData {
label: string;
value: string;
color: string;
}

View File

@@ -5,8 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../charts/types';
} from '../../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */

View File

@@ -3,7 +3,11 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -27,6 +31,17 @@ export function getScaledFontSize({
return Math.min(baseSize * scaleFactor, maxSize);
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
/**
* Computes the leader-line / label geometry for one arc from its angular span.
* Pulled out of the render prop so the SVG markup stays declarative.
@@ -48,6 +63,12 @@ export function getArcGeometry(
};
}
interface ParsedRgb {
r: number;
g: number;
b: number;
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {

View File

@@ -1,147 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from '../../charts/types';
import { usePieInteractions } from '../usePieInteractions';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
);
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#a' },
{ label: 'cart', value: 60, color: '#b' },
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('starts with everything visible and nothing focused', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
true,
true,
]);
expect(result.current.focusedSeriesIndex).toBeNull();
expect(result.current.active).toBeNull();
});
describe('marker click (toggle one)', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
false,
false,
]);
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual(DATA);
});
});
describe('hover', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendMouseMove(legendEvent(2)));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
expect(result.current.active).toBeNull();
});
});
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, true)));
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
mockGetStored.mockReturnValue([
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
});
});
});

View File

@@ -72,7 +72,7 @@ export const deploymentWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
title: 'Memory usage, request, limits)',
yAxisUnit: 'bytes',
},
{

View File

@@ -69,7 +69,7 @@ export const jobWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory Usage',
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{

View File

@@ -703,7 +703,7 @@ export const getNamespaceMetricsQueryPayload = (
],
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: 10,
limit: 20,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
@@ -1014,8 +1014,8 @@ export const getNamespaceMetricsQueryPayload = (
id: '5f2a55c5',
key: {
dataType: DataTypes.String,
id: k8sNamespaceNameKey,
key: k8sNamespaceNameKey,
id: k8sStatefulsetNameKey,
key: k8sStatefulsetNameKey,
type: 'tag',
},
op: '=',

View File

@@ -317,9 +317,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_used--float64--Gauge--true',
id: 'k8s_volume_inodes_used--float64----true',
key: k8sVolumeInodesUsedKey,
type: 'Gauge',
type: '',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -409,9 +409,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes--float64--Gauge--true',
id: 'k8s_volume_inodes--float64----true',
key: k8sVolumeInodesKey,
type: 'Gauge',
type: '',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -501,9 +501,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_free--float64--Gauge--true',
id: 'k8s_volume_inodes_free--float64----true',
key: k8sVolumeInodesFreeKey,
type: 'Gauge',
type: '',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,

View File

@@ -1619,9 +1619,6 @@ export const getHostQueryPayload = (
const diskOpTimeKey = dotMetricsEnabled
? 'system.disk.operation_time'
: 'system_disk_operation_time';
const diskOpsKey = dotMetricsEnabled
? 'system.disk.operations'
: 'system_disk_operations';
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
@@ -2378,24 +2375,9 @@ export const getHostQueryPayload = (
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
],
groupBy: [],
having: [],
legend: '{{device}}::{{direction}}',
legend: 'system disk io',
limit: null,
orderBy: [],
queryName: 'A',
@@ -2427,9 +2409,9 @@ export const getHostQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operations--float64--Sum--true',
id: 'system_disk_operation_time--float64--Sum--true',
key: diskOpsKey,
key: diskOpTimeKey,
type: 'Sum',
},
aggregateOperator: 'rate',
@@ -2439,7 +2421,7 @@ export const getHostQueryPayload = (
filters: {
items: [
{
id: 'diskops_f1',
id: 'diskop_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
@@ -2472,7 +2454,7 @@ export const getHostQueryPayload = (
],
having: [
{
columnName: `SUM(${diskOpsKey})`,
columnName: `SUM(${diskOpTimeKey})`,
op: '>',
value: 0,
},
@@ -2575,88 +2557,6 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operation_time--float64--Sum--true',
key: diskOpTimeKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'diskoptime_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
op: '>',
value: 0,
},
],
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a8b3d2e1-4f5c-4a6b-9c8d-7e2f1a0b3c4f',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2731,5 +2631,5 @@ export const hostWidgetInfo = [
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
{ title: 'Disk operations time', yAxisUnit: 's' },
];

View File

@@ -96,28 +96,14 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const { domainToAdminEmailList, ...rest } = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
domainToAdminEmail: domainToAdminEmail ?? {},
};
}, [form]);
@@ -143,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
groupMappings: groupMappings ?? {},
};
}, [form]);

View File

@@ -1,195 +0,0 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcWithClaimMapping,
mockSamlWithAttributeMapping,
mockUpdateSuccessResponse,
} from './mocks';
// @signozhq/ui/button internal effects block form.validateFields() in tests
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
type SavedPayload = {
config: {
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
};
};
async function submitForm(
record: AuthtypesGettableAuthDomainDTO,
): Promise<SavedPayload> {
const requests: SavedPayload[] = [];
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
requests.push((await req.json()) as SavedPayload);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(<CreateEdit isCreate={false} record={record} onClose={jest.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(requests).toHaveLength(1));
return requests[0];
}
describe('CreateEdit — payload sanitization', () => {
afterEach(() => server.resetHandlers());
describe('Google Auth', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.googleAuthConfig;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('strips workspace fields when fetchGroups is false', async () => {
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
fetchGroups: false,
},
},
});
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
expect(g?.allowedGroups).toStrictEqual([
'allowed-group-1',
'allowed-group-2',
]);
expect(g?.domainToAdminEmail).toStrictEqual({
'google-groups.com': 'admin@google-groups.com',
});
});
});
describe('SAML', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
expect(attr?.name).toBe('user_display_name');
expect(attr?.groups).toBe('member_of');
expect(attr?.role).toBe('signoz_role');
});
});
describe('OIDC', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.oidcConfig;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
expect(o?.clientSecret).toBe('claims-client-secret');
expect(o?.insecureSkipEmailVerified).toBe(true);
expect(o?.getUserInfo).toBe(true);
const claim = o?.claimMapping as Record<string, unknown>;
expect(claim?.email).toBe('user_email');
expect(claim?.name).toBe('display_name');
expect(claim?.groups).toBe('user_groups');
expect(claim?.role).toBe('user_role');
});
});
describe('Role Mapping', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
},
});
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
});
});
});
});

View File

@@ -22,12 +22,11 @@ export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
export const TagContainer = styled(Badge)`
&&& {
display: flex;
border-radius: 3px;
padding: 0.1rem 0.2rem;
font-weight: 300;
font-size: 0.6rem;
}
@@ -39,5 +38,4 @@ export const TagLabel = styled.span`
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -173,15 +173,17 @@
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
display: flex;
opacity: 0;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
margin: 0;
border: none;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
border-radius: 4px;
transition:
opacity 0.15s ease,

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
@@ -22,20 +21,20 @@ export const MAX_LEGEND_WIDTH = 240;
*/
export default function Legend({
items,
position,
position = LegendPosition.BOTTOM,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
focusedSeriesIndex = null,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
showSearch,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const searchEnabled = showSearch ?? position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
@@ -80,7 +79,7 @@ export default function Legend({
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<TooltipSimple title={item.label} arrow side="top">
<div className="legend-item-label-trigger">
<div
className="legend-marker"
@@ -91,30 +90,18 @@ export default function Legend({
</div>
</TooltipSimple>
{showCopy && (
<TooltipSimple
title={isCopied ? 'Copied' : 'Copy'}
arrow
side="top"
disableHoverableContent
>
<Button
<TooltipSimple title={isCopied ? 'Copied' : 'Copy'} arrow side="top">
<button
type="button"
size="icon"
variant="ghost"
color="secondary"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
// data-testid (not testId): TooltipSimple's trigger injects
// data-testid:undefined via Radix Slot, and Button spreads
// incoming props after its own testId — so set it as a prop
// that wins the Slot merge and survives the spread.
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</button>
</TooltipSimple>
)}
</div>

View File

@@ -111,27 +111,27 @@ export interface LegendConfig {
position: LegendPosition;
}
/**
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
* Presentational legend props. Source-agnostic: it renders whatever
* `items` it's given and delegates interaction to the container handlers, so
* it serves both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
*/
export interface LegendProps {
items: LegendItem[];
/** Legend placement; always supplied by the container. */
position: LegendPosition;
position?: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex: number | null;
focusedSeriesIndex?: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseMove?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Show the filter search box. Default: only for the RIGHT position. */
showSearch?: boolean;
}
/** Props for the uPlot legend controller, which derives items + interaction

View File

@@ -7,10 +7,20 @@ import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardPageV2 />;
}
return <DashboardPageV1 />;
}
function DashboardPageV1(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();

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 '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildBarChartConfig } from './config';
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,9 @@
import type { SectionConfig } from '../types';
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 '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import { resolveLegendPosition } from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildHistogramConfig } from './config';
import { prepareHistogramData } from './data';
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,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';
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 '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { preparePieData } from './data';
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,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';
// 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 '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './config';
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,15 @@
import type { SectionConfig } from '../types';
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,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,70 @@
import { DataSource } from 'types/common/queryBuilder';
import BarPanelRenderer from './BarPanel/Renderer';
import { sections as barSections } from './BarPanel/sections';
import HistogramPanelRenderer from './HistogramPanel/Renderer';
import { sections as histogramSections } from './HistogramPanel/sections';
import PiePanelRenderer from './PiePanel/Renderer';
import { sections as pieSections } from './PiePanel/sections';
import TimeSeriesRenderer from './TimeSeriesPanel/Renderer';
import { sections as timeSeriesSections } from './TimeSeriesPanel/sections';
import type {
PanelDefinition,
PanelKind,
PanelRegistry,
RenderablePanelDefinition,
} from './types';
const TimeSeriesPanelDef: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer: TimeSeriesRenderer,
sections: timeSeriesSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const BarChartPanelDef: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer: BarPanelRenderer,
sections: barSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const HistogramPanelDef: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer: HistogramPanelRenderer,
sections: histogramSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const PiePanelDef: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer: PiePanelRenderer,
sections: pieSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
export const PANELS: PanelRegistry = {
'signoz/TimeSeriesPanel': TimeSeriesPanelDef,
'signoz/BarChartPanel': BarChartPanelDef,
'signoz/HistogramPanel': HistogramPanelDef,
'signoz/PieChartPanel': PiePanelDef,
};
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,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
* types.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,9 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,177 @@
import type { ComponentType } from 'react';
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
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 { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DataSource } from 'types/common/queryBuilder';
import type {
AnyPanelInteractionProps,
PanelInteractionMap,
} from './interactions';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// 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;
// 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 };
};
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];
/**
* 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];
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>;
}
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,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,110 @@
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 {
DashboardPreference,
RenderablePanelDefinition,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
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';
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,130 @@
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';
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';
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

@@ -1,3 +1,13 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
export default DashboardsListPage;
function DashboardsListPageEntry(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardsListPageV2 />;
}
return <DashboardsListPage />;
}
export default DashboardsListPageEntry;

View File

@@ -27,9 +27,9 @@
color: white;
}
// Shared trigger button for the sort + configure-group icons in the right
// actions cluster. Provides a square hover/active background so users know
// which icon they're targeting.
/* Shared trigger button for the sort + configure-group icons in the right
actions cluster. Provides a square hover/active background so users know
which icon they are targeting. */
.iconTrigger {
display: inline-flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
/* Shared building blocks for the dashboards-list view states. */
/* Composed via CSS-modules composes: from each state's own SCSS. */
.cardWrapper {
display: flex;

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

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

@@ -10,6 +10,25 @@ import (
)
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfall),
handler.OpenAPIDef{
ID: "GetWaterfall",
Tags: []string{"tracedetail"},
Summary: "Get waterfall view for a trace",
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
Request: new(spantypes.PostableWaterfall),
RequestContentType: "application/json",
Response: new(spantypes.GettableWaterfallTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
handler.OpenAPIDef{
@@ -48,24 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
handler.OpenAPIDef{
ID: "GetFlamegraph",
Tags: []string{"tracedetail"},
Summary: "Get flamegraph view for a trace",
Description: "Returns the flamegraph view of spans for a given trace ID.",
Request: new(spantypes.PostableFlamegraph),
RequestContentType: "application/json",
Response: new(spantypes.GettableFlamegraphTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"net/url"
"path"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -15,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -31,13 +29,12 @@ var scopes []string = []string{"email", "profile"}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
globalConfig global.Config
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
}
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -46,10 +43,9 @@ func New(ctx context.Context, store authtypes.AuthNStore, providerSettings facto
}
return &AuthN{
store: store,
settings: settings,
httpClient: httpClient,
globalConfig: globalConfig,
store: store,
settings: settings,
httpClient: httpClient,
}, nil
}
@@ -182,7 +178,7 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
Path: redirectPath,
}).String(),
}
}

View File

@@ -1 +0,0 @@
<svg id="uuid-c6c3f75e-5369-448e-b895-3f99fb11bebe" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M7.456.608c-.902-.411-1.909-.559-2.898-.417.053.041.086.107.082.179l-.082,1.405c.879-.183,1.827-.043,2.65.469.338.21.639.474.892.781,0,0,.024.027.061.069.091.104.26.299.334.402.006.031-.004.062-.026.084-.001.001-.002.002-.003.004l-.052.048-.765.681c-.039.035-.042.095-.007.134.017.019.04.03.065.031l1.107.065,1.402.082c.072.004.138-.029.179-.083.025-.033.041-.073.044-.117l.147-2.513c.003-.052-.037-.097-.089-.1-.025-.001-.049.007-.068.024l-.764.682v.003c-.106-.164-.22-.319-.34-.467-.516-.636-1.159-1.122-1.869-1.445Z" fill="#0078d4"/><path d="M4.441.147L1.932,0c-.052-.003-.097.037-.1.09-.001.025.007.049.024.068l.681.766h.003c-.159.104-.311.214-.455.331-.629.509-1.111,1.143-1.436,1.842-.424.913-.578,1.937-.434,2.942.041-.053.107-.086.179-.082l1.402.082c-.183-.881-.043-1.83.468-2.655.209-.338.473-.64.78-.893,0,0,.029-.026.072-.064.104-.092.297-.259.399-.332.031-.006.062.004.084.026.001.001.002.002.003.003l.048.052.679.766c.035.039.095.042.134.008.019-.017.03-.04.031-.065l.064-1.109.082-1.405c.004-.072-.029-.138-.082-.179-.033-.025-.073-.041-.117-.044Z" fill="#46a0de"/><path d="M10.411,5.611c.025-.363.013-.73-.039-1.095-.041.053-.107.086-.179.082l-1.402-.082c.038.186.062.374.071.564l1.55.53Z" fill="#155ea1"/><path d="M3.576,9.604l.271-.049,1.845-.343c-.095-.084-.155-.206-.155-.34v-.025c-.733.051-1.487-.119-2.159-.536-.338-.21-.639-.474-.892-.781,0,0-.024-.027-.061-.069-.091-.104-.26-.299-.334-.402-.006-.031.004-.062.026-.084.001-.001.002-.002.003-.004l.052-.048.765-.681c.039-.035.042-.095.007-.134-.017-.019-.04-.03-.065-.031l-1.107-.065-1.402-.082c-.072-.004-.138.029-.179.083-.025.033-.041.073-.044.117L0,8.645c-.003.052.037.097.089.1.025.001.049-.007.068-.024l.764-.682v-.003c.106.164.22.319.34.467.516.636,1.159,1.122,1.869,1.445.026.012.053.021.08.033.029-.188.173-.342.365-.376Z" fill="#8dc8e8"/><g><polygon points="8.241 5.343 5.968 5.765 5.968 8.87 8.241 9.355 10.522 8.44 10.522 6.123 8.241 5.343" fill="#8661c5"/><path d="M8.328,9.307l2.082-.844c.048-.019.084-.061.095-.111v-2.102c-.004-.064-.044-.119-.103-.143l-2.106-.716h-.095l-2.066.382c-.066.017-.114.075-.119.143v2.81c-.002.073.048.136.119.151l2.09.438c.035.004.07.002.103-.008Z" fill="none"/><path d="M5.968,5.765v3.105l2.297.486v-3.98l-2.297.39ZM6.938,8.631l-.644-.127v-2.388l.644-.103v2.619ZM7.939,8.814l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="13.16 5.383 10.887 5.805 10.887 8.909 13.16 9.395 15.433 8.471 15.433 6.163 13.16 5.383" fill="#8661c5"/><path d="M10.887,5.805v3.105l2.281.486v-3.98l-2.281.39ZM11.849,8.67l-.644-.127v-2.388l.644-.103v2.619ZM12.85,8.854l-.739-.119v-2.73l.739-.135v2.985Z" fill="#56407f"/><polygon points="5.912 9.626 3.639 10.048 3.639 13.152 5.912 13.638 8.193 12.722 8.193 10.406 5.912 9.626" fill="#8661c5"/><path d="M3.632,10.048v3.081l2.297.486v-3.98l-2.297.414ZM4.593,12.921l-.644-.135v-2.388l.644-.111v2.635ZM5.602,13.128l-.739-.119v-2.762l.739-.127v3.009Z" fill="#56407f"/><polygon points="10.816 9.594 8.543 10.016 8.543 13.12 10.816 13.614 13.089 12.69 13.089 10.374 10.816 9.594" fill="#8661c5"/><path d="M8.543,10.016v3.112l2.289.486v-3.98l-2.289.382ZM9.504,12.889l-.644-.135v-2.388l.644-.111v2.635ZM10.506,13.065l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="15.719 9.634 13.446 10.056 13.446 13.16 15.719 13.646 18 12.73 18 10.414 15.719 9.634" fill="#8661c5"/><path d="M13.446,10.056v3.073l2.297.486v-3.98l-2.297.422ZM14.416,12.929l-.644-.135v-2.388l.644-.111v2.635ZM15.417,13.104l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="8.185 13.956 5.912 14.37 5.912 17.475 8.185 17.968 10.466 17.045 10.466 14.736 8.185 13.956" fill="#8661c5"/><path d="M8.273,17.904l2.074-.796c.06-.021.099-.08.095-.143v-2.07c.012-.076-.031-.149-.103-.175l-2.098-.716c-.031-.012-.065-.012-.095,0l-2.066.374c-.074.012-.128.076-.127.151v2.818c-.002.073.048.136.119.151l2.09.406c.036.012.075.012.111,0Z" fill="none"/><path d="M5.912,14.37v3.105l2.297.494v-4.044l-2.297.446ZM6.882,17.244l-.644-.135v-2.388l.644-.111v2.635ZM7.883,17.427l-.739-.119v-2.738l.739-.127v2.985Z" fill="#56407f"/><polygon points="13.097 13.988 10.824 14.41 10.824 17.514 13.097 18 15.377 17.085 15.377 14.768 13.097 13.988" fill="#8661c5"/><path d="M10.824,14.41v3.105l2.297.486v-3.98l-2.297.39ZM11.793,17.284l-.644-.135v-2.388l.644-.111v2.635ZM12.795,17.459l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,293 +0,0 @@
{
"id": "aks",
"title": "Azure Kubernetes Service (AKS)",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_kube_pod_status_ready_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_ready_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.ContainerService",
"resourceType": "managedClusters",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure Kubernetes Service (AKS) Overview",
"description": "Overview of Azure Kubernetes Service (AKS) metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,5 +0,0 @@
### Monitor Azure Kubernetes Service (AKS) with SigNoz
Collect key AKS metrics and view them with an out of the box dashboard.
Note: This integration is only for AKS with resource type `Microsoft.ContainerService/managedClusters`.

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,263 +0,0 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,7 +0,0 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -219,7 +219,19 @@ func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsex
return nil, err
}
metricStats, total, err := m.fetchMetricsStatsWithSamples(ctx, req, false)
filterWhereClause, err := m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
// Single query to get stats with samples, timeseries counts in required sorting order
metricStats, total, err := m.fetchMetricsStatsWithSamples(
ctx,
req,
filterWhereClause,
false,
req.OrderBy,
)
if err != nil {
return nil, err
}
@@ -256,16 +268,21 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
return nil, err
}
filterWhereClause, err := m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
resp := &metricsexplorertypes.TreemapResponse{}
switch req.Mode {
case metricsexplorertypes.TreemapModeSamples:
entries, err := m.computeSamplesTreemap(ctx, req)
entries, err := m.computeSamplesTreemap(ctx, req, filterWhereClause)
if err != nil {
return nil, err
}
resp.Samples = entries
default: // TreemapModeTimeSeries
entries, err := m.computeTimeseriesTreemap(ctx, req)
entries, err := m.computeTimeseriesTreemap(ctx, req, filterWhereClause)
if err != nil {
return nil, err
}
@@ -957,23 +974,15 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
func (m *module) fetchMetricsStatsWithSamples(
ctx context.Context,
req *metricsexplorertypes.StatsRequest,
filterWhereClause *sqlbuilder.WhereClause,
normalized bool,
orderBy *qbtypes.OrderBy,
) ([]metricsexplorertypes.Stat, uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricsStatsWithSamples")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, 0, err
}
}
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
samplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
// Timeseries counts per metric
tsSB := sqlbuilder.NewSelectBuilder()
@@ -996,7 +1005,7 @@ func (m *module) fetchMetricsStatsWithSamples(
"metric_name",
fmt.Sprintf("%s AS samples", countExp),
)
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
samplesSB.Where(samplesSB.Between("unix_milli", req.Start, req.End))
samplesSB.Where("NOT startsWith(metric_name, 'signoz')")
@@ -1004,8 +1013,6 @@ func (m *module) fetchMetricsStatsWithSamples(
sqlbuilder.CTEQuery("__time_series_counts").As(tsSB),
}
// Narrow samples scan. With filter: fingerprint IN (per-fingerprint label preds can't fold to metric_name).
// No filter (fast path): metric_name IN — aligns with samples table's leading sort key, orders of magnitude cheaper.
if filterWhereClause != nil {
fingerprintSB := sqlbuilder.NewSelectBuilder()
fingerprintSB.Select("fingerprint")
@@ -1018,15 +1025,6 @@ func (m *module) fetchMetricsStatsWithSamples(
ctes = append(ctes, sqlbuilder.CTEQuery("__filtered_fingerprints").As(fingerprintSB))
samplesSB.Where("fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints)")
} else {
metricNamesSB := sqlbuilder.NewSelectBuilder()
metricNamesSB.Select("DISTINCT metric_name")
metricNamesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
metricNamesSB.Where(metricNamesSB.Between("unix_milli", start, end))
metricNamesSB.Where("NOT startsWith(metric_name, 'signoz')")
metricNamesSB.Where(metricNamesSB.E("__normalized", normalized))
samplesSB.Where(fmt.Sprintf("metric_name IN (%s)", samplesSB.Var(metricNamesSB)))
}
samplesSB.GroupBy("metric_name")
@@ -1043,7 +1041,7 @@ func (m *module) fetchMetricsStatsWithSamples(
finalSB.JoinWithOption(sqlbuilder.FullOuterJoin, "__sample_counts s", "ts.metric_name = s.metric_name")
finalSB.Where("(COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0)")
orderByColumn, orderDirection, err := getStatsOrderByColumn(req.OrderBy)
orderByColumn, orderDirection, err := getStatsOrderByColumn(orderBy)
if err != nil {
return nil, 0, err
}
@@ -1087,19 +1085,9 @@ func (m *module) fetchMetricsStatsWithSamples(
return metricStats, total, nil
}
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest) ([]metricsexplorertypes.TreemapEntry, error) {
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeTimeseriesTreemap")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
}
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
totalTSBuilder := sqlbuilder.NewSelectBuilder()
@@ -1163,22 +1151,12 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
return entries, nil
}
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest) ([]metricsexplorertypes.TreemapEntry, error) {
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeSamplesTreemap")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
}
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
samplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
candidateLimit := req.Limit + 50
@@ -1201,7 +1179,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
totalSamplesSB := sqlbuilder.NewSelectBuilder()
totalSamplesSB.Select(fmt.Sprintf("%s AS total_samples", countExp))
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
totalSamplesSB.Where(totalSamplesSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB := sqlbuilder.NewSelectBuilder()
@@ -1209,7 +1187,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
"metric_name",
fmt.Sprintf("%s AS samples", countExp),
)
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")

View File

@@ -1,270 +0,0 @@
package implmetricsexplorer_test
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/cachetest"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
const (
// fixed, deterministic time window (range < 6h so the table selectors resolve
// to stable table names that the expected SQL strings can hard-code).
testStartMillis int64 = 1700000000000
testEndMillis int64 = 1700003600000 // +1h
statsNoFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ?) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ?"
statsOrderTimeseriesSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ?) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY timeseries ASC, metric_name ASC LIMIT ? OFFSET ?"
statsWithFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ?"
treemapTimeseriesNoFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name) SELECT mt.metric_name, mt.total_value, CASE WHEN tts.total_time_series = 0 THEN 0 ELSE (mt.total_value * 100.0 / tts.total_time_series) END AS percentage FROM __metric_totals mt JOIN __total_time_series tts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapTimeseriesWithFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name) SELECT mt.metric_name, mt.total_value, CASE WHEN tts.total_time_series = 0 THEN 0 ELSE (mt.total_value * 100.0 / tts.total_time_series) END AS percentage FROM __metric_totals mt JOIN __total_time_series tts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapSamplesNoFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND unix_milli BETWEEN ? AND ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?) SELECT mc.metric_name, COALESCE(sc.samples, 0) AS samples, CASE WHEN ts.total_samples = 0 THEN 0 ELSE (COALESCE(sc.samples, 0) * 100.0 / ts.total_samples) END AS percentage FROM __metric_candidates mc LEFT JOIN __sample_counts sc ON mc.metric_name = sc.metric_name JOIN __total_samples ts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapSamplesWithFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?) SELECT mc.metric_name, COALESCE(sc.samples, 0) AS samples, CASE WHEN ts.total_samples = 0 THEN 0 ELSE (COALESCE(sc.samples, 0) * 100.0 / ts.total_samples) END AS percentage FROM __metric_candidates mc LEFT JOIN __sample_counts sc ON mc.metric_name = sc.metric_name JOIN __total_samples ts ON 1=1 ORDER BY percentage DESC LIMIT ?"
)
var testOrgID = valuer.GenerateUUID()
type statsOpt func(*metricsexplorertypes.StatsRequest)
type treemapOpt func(*metricsexplorertypes.TreemapRequest)
// newTestModule builds the metricsexplorer module backed by a mocked clickhouse
// connection, a mock metadata store, and an in-memory cache.
func newTestModule(t *testing.T, matcher sqlmock.QueryMatcher) (metricsexplorer.Module, cmock.ClickConnMockCommon, *telemetrytypestest.MockMetadataStore) {
t.Helper()
ts := telemetrystoretest.New(telemetrystore.Config{}, matcher)
md := telemetrytypestest.NewMockMetadataStore()
c, err := cachetest.New(cache.Config{Provider: "memory", Memory: cache.Memory{NumCounters: 1000, MaxCost: 1 << 20}})
if err != nil {
t.Fatalf("cachetest.New: %v", err)
}
settings := instrumentationtest.New().ToProviderSettings()
mod := implmetricsexplorer.NewModule(ts, md, c, nil /*ruleStore*/, nil /*dashboardModule*/, settings, metricsexplorer.Config{})
return mod, ts.Mock(), md
}
func statsRequest(opts ...statsOpt) *metricsexplorertypes.StatsRequest {
req := &metricsexplorertypes.StatsRequest{
Start: testStartMillis,
End: testEndMillis,
Limit: 10,
}
for _, o := range opts {
o(req)
}
return req
}
func withStatsFilter(expr string) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.Filter = &qbtypes.Filter{Expression: expr}
}
}
func withStatsLimit(limit int) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.Limit = limit
}
}
func withStatsOrderBy(name string, dir qbtypes.OrderDirection) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name}},
Direction: dir,
}
}
}
func treemapRequest(mode metricsexplorertypes.TreemapMode, opts ...treemapOpt) *metricsexplorertypes.TreemapRequest {
req := &metricsexplorertypes.TreemapRequest{
Start: testStartMillis,
End: testEndMillis,
Limit: 10,
Mode: mode,
}
for _, o := range opts {
o(req)
}
return req
}
func withTreemapFilter(expr string) treemapOpt {
return func(req *metricsexplorertypes.TreemapRequest) {
req.Filter = &qbtypes.Filter{Expression: expr}
}
}
// seedFilterKey registers a string attribute field so buildFilterClause can
// resolve it when parsing a filter expression that references it.
func seedFilterKey(md *telemetrytypestest.MockMetadataStore, name string) {
md.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{
{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
}
// anyArgs returns n nil wildcards. cmock treats a nil expected arg as a match
// for any actual value, so this asserts only the bound-arg count, not values
// (the SQL text itself is what we verify). The count must match the query.
func anyArgs(n int) []any {
return make([]any, n)
}
func treemapEntryRows() *cmock.Rows {
return cmock.NewRows(
[]cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "total_value", Type: "UInt64"},
{Name: "percentage", Type: "Float64"},
},
[][]any{
{"metric_a", uint64(50), 50.0},
{"metric_b", uint64(30), 30.0},
},
)
}
func TestGetStats(t *testing.T) {
tests := []struct {
name string
opts []statsOpt
seedKey string
queryErr error
expectSQL string
argCount int
noQuery bool // SQL never reaches clickhouse (validation/build error)
wantCode errors.Code
}{
{name: "NoFilter_FastPathSQL", expectSQL: statsNoFilterSQL, argCount: 10},
{name: "WhitespaceFilter_FastPathSQL", opts: []statsOpt{withStatsFilter(" ")}, expectSQL: statsNoFilterSQL, argCount: 10},
{name: "WithFilter_FingerprintSQL", opts: []statsOpt{withStatsFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: statsWithFilterSQL, argCount: 12},
{name: "OrderByTimeseriesAsc", opts: []statsOpt{withStatsOrderBy("timeseries", qbtypes.OrderDirectionAsc)}, expectSQL: statsOrderTimeseriesSQL, argCount: 10},
{name: "OrderByInvalid", opts: []statsOpt{withStatsOrderBy("nonsense", qbtypes.OrderDirectionAsc)}, noQuery: true, wantCode: errors.CodeInvalidInput},
{name: "QueryError", queryErr: assert.AnError, expectSQL: statsNoFilterSQL, argCount: 10, wantCode: errors.CodeInternal},
{name: "InvalidRequest_Limit", opts: []statsOpt{withStatsLimit(0)}, noQuery: true, wantCode: errors.CodeInvalidInput},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod, mock, md := newTestModule(t, sqlmock.QueryMatcherRegexp)
if tc.seedKey != "" {
seedFilterKey(md, tc.seedKey)
}
if !tc.noQuery {
eq := mock.ExpectQuery(regexp.QuoteMeta(tc.expectSQL)).WithArgs(anyArgs(tc.argCount)...)
if tc.queryErr != nil {
eq.WillReturnError(tc.queryErr)
} else {
eq.WillReturnRows(cmock.NewRows(nil, nil))
}
}
_, err := mod.GetStats(context.Background(), testOrgID, statsRequest(tc.opts...))
if tc.wantCode.String() != "" {
assert.Error(t, err)
assert.Truef(t, errors.Asc(err, tc.wantCode), "want code %s, got %v", tc.wantCode, err)
return
}
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestGetTreemap(t *testing.T) {
wantEntries := []metricsexplorertypes.TreemapEntry{
{MetricName: "metric_a", TotalValue: 50, Percentage: 50.0},
{MetricName: "metric_b", TotalValue: 30, Percentage: 30.0},
}
tests := []struct {
name string
mode metricsexplorertypes.TreemapMode
opts []treemapOpt
seedKey string
queryErr error
expectSQL string
argCount int
rows *cmock.Rows
wantSamples []metricsexplorertypes.TreemapEntry
wantTS []metricsexplorertypes.TreemapEntry
noQuery bool
wantCode errors.Code
wantErr bool
}{
{name: "TimeSeries_NoFilter_SQL", mode: metricsexplorertypes.TreemapModeTimeSeries, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7},
{name: "TimeSeries_WithFilter_SQL", mode: metricsexplorertypes.TreemapModeTimeSeries, opts: []treemapOpt{withTreemapFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: treemapTimeseriesWithFilterSQL, argCount: 8},
{name: "TimeSeries_ScansEntries", mode: metricsexplorertypes.TreemapModeTimeSeries, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7, rows: treemapEntryRows(), wantTS: wantEntries},
{name: "Samples_NoFilter_SQL", mode: metricsexplorertypes.TreemapModeSamples, expectSQL: treemapSamplesNoFilterSQL, argCount: 9},
{name: "Samples_WithFilter_SQL", mode: metricsexplorertypes.TreemapModeSamples, opts: []treemapOpt{withTreemapFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: treemapSamplesWithFilterSQL, argCount: 14},
{name: "Samples_ScansEntries", mode: metricsexplorertypes.TreemapModeSamples, expectSQL: treemapSamplesNoFilterSQL, argCount: 9, rows: treemapEntryRows(), wantSamples: wantEntries},
{name: "FilterBuildError", mode: metricsexplorertypes.TreemapModeTimeSeries, opts: []treemapOpt{withTreemapFilter("host.name =")}, noQuery: true, wantErr: true},
{name: "QueryError", mode: metricsexplorertypes.TreemapModeTimeSeries, queryErr: assert.AnError, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7, wantCode: errors.CodeInternal},
{name: "InvalidMode", mode: metricsexplorertypes.TreemapMode{}, noQuery: true, wantCode: errors.CodeInvalidInput},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod, mock, md := newTestModule(t, sqlmock.QueryMatcherRegexp)
if tc.seedKey != "" {
seedFilterKey(md, tc.seedKey)
}
if !tc.noQuery {
eq := mock.ExpectQuery(regexp.QuoteMeta(tc.expectSQL)).WithArgs(anyArgs(tc.argCount)...)
switch {
case tc.queryErr != nil:
eq.WillReturnError(tc.queryErr)
case tc.rows != nil:
eq.WillReturnRows(tc.rows)
default:
eq.WillReturnRows(cmock.NewRows(nil, nil))
}
}
resp, err := mod.GetTreemap(context.Background(), testOrgID, treemapRequest(tc.mode, tc.opts...))
switch {
case tc.wantCode.String() != "":
assert.Error(t, err)
assert.Truef(t, errors.Asc(err, tc.wantCode), "want code %s, got %v", tc.wantCode, err)
return
case tc.wantErr:
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
if tc.wantTS != nil {
assert.Equal(t, tc.wantTS, resp.TimeSeries)
}
if tc.wantSamples != nil {
assert.Equal(t, tc.wantSamples, resp.Samples)
}
})
}
}

View File

@@ -4,11 +4,9 @@ import (
"context"
"net/http"
"net/url"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -17,12 +15,11 @@ import (
)
type handler struct {
module session.Module
globalConfig global.Config
module session.Module
}
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
return &handler{module: module, globalConfig: globalConfig}
func NewHandler(module session.Module) session.Handler {
return &handler{module: module}
}
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
@@ -161,13 +158,13 @@ func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request)
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) getRedirectURLFromErr(err error) string {
func (*handler) getRedirectURLFromErr(err error) string {
values := errors.AsURLValues(err)
values.Add("callbackauthnerr", "true")
return (&url.URL{
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
Path: "/login",
RawQuery: values.Encode(),
}).String()
}

View File

@@ -6,16 +6,7 @@ import (
)
type Config struct {
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
}
type FlamegraphConfig struct {
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
Waterfall WaterfallConfig `mapstructure:"waterfall"`
}
type WaterfallConfig struct {
@@ -38,13 +29,6 @@ func newConfig() factory.Config {
MaxDepthToAutoExpand: 5,
MaxLimitToSelectAllSpans: 10_000,
},
Flamegraph: FlamegraphConfig{
MaxSelectedLevels: 50,
MaxSpansPerLevel: 100,
SamplingTopLatencySpansCount: 5,
SamplingBucketCount: 50,
SelectAllSpansLimit: 100_000,
},
}
}
@@ -58,20 +42,5 @@ func (c Config) Validate() error {
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
}
if c.Flamegraph.MaxSelectedLevels <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.max_selected_levels must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
}
if c.Flamegraph.MaxSpansPerLevel <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.max_spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
}
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.sampling_top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
}
if c.Flamegraph.SamplingBucketCount <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.sampling_bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
}
if c.Flamegraph.SelectAllSpansLimit == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.select_all_spans_limit must be positive")
}
return nil
}

View File

@@ -18,6 +18,27 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
return &handler{module: module}
}
func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetWaterfall(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
@@ -30,7 +51,7 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
return
}
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans, req.Limit)
if err != nil {
render.Error(rw, err)
return
@@ -59,19 +80,3 @@ func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request)
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableFlamegraph)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.SelectFields)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"go.opentelemetry.io/otel/metric"
)
@@ -34,21 +33,66 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
}
m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)))
m.metrics.flamegraphSpanLimit.Record(context.Background(), int64(cfg.Flamegraph.SelectAllSpansLimit), metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m
}
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
waterfallTrace, err := m.getTraceData(ctx, traceID)
if err != nil {
return nil, err
}
selectedSpans, uncollapsedSpans, selectedAllSpans := waterfallTrace.GetWaterfallSpans(
req.UncollapsedSpans,
req.SelectedSpanID,
min(req.Limit, m.config.Waterfall.MaxLimitToSelectAllSpans),
m.config.Waterfall.SpanPageSize,
m.config.Waterfall.MaxDepthToAutoExpand,
)
aggregationResults := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, a := range req.Aggregations {
aggregationResults = append(aggregationResults, waterfallTrace.GetSpanAggregation(a.Aggregation, a.Field))
}
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
}
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
if len(spanItems) == 0 {
return nil, spantypes.ErrTraceNotFound
}
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
for i := range spanItems {
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
}
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
@@ -74,7 +118,7 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
selectedSpans := waterfallTrace.GetAllSpans()
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true), nil
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
@@ -120,18 +164,6 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
}
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window
@@ -169,50 +201,6 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
return spantypes.NewGettableWaterfallTrace(
waterfallTrace, selectedSpans, uncollapsedSpans, false,
), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {
return nil, err
}
if len(fullSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
cfg := m.config.Flamegraph
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
if len(selectedSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
), nil
}

View File

@@ -154,47 +154,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"span_id",
"any(parent_span_id) AS parent_span_id",
"any(timestamp) AS timestamp",
"any(duration_nano) AS duration_nano",
"any(has_error) AS has_error",
"any(name) AS name",
"any(events) AS events",
"any(attributes_string) AS attributes_string",
"any(attributes_number) AS attributes_number",
"any(attributes_bool) AS attributes_bool",
"any(resources_string) AS resources_string",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
conditions := []string{
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", start.Unix()-1800),
sb.LE("ts_bucket_start", end.Unix()),
}
if len(spanIDs) > 0 {
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
ids[i] = id
}
conditions = append(conditions, sb.In("span_id", ids...))
}
sb.Where(conditions...)
sb.GroupBy("span_id")
sb.OrderByAsc("timestamp")
sb.OrderByAsc("name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying flamegraph spans")
}
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {

View File

@@ -91,30 +91,6 @@ func TestGetSpanCountByField(t *testing.T) {
}
}
func TestGetFlamegraphSpans(t *testing.T) {
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
tests := []struct {
name string
spanIDs []string
sql string
}{
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
assert.NoError(t, s.Mock().ExpectationsWereMet())
})
}
}
func TestGetSpanDurationByField(t *testing.T) {
expectedSQL := "WITH all_spans AS (SELECT DISTINCT ON (span_id) resource.`service.name`::String AS field_value, toUnixTimestamp64Nano(timestamp) AS start_ns, start_ns + duration_nano AS end_ns FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND notEmpty(field_value) ORDER BY timestamp ASC, name ASC), effective_start AS (SELECT field_value, end_ns, greatest(start_ns, ifNull(max(end_ns) OVER (PARTITION BY field_value ORDER BY start_ns ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), toUInt64(0))) AS effective_start_ns FROM all_spans) SELECT field_value, sum(toUInt64(greatest(end_ns - effective_start_ns, 0))) AS total_ns FROM effective_start GROUP BY field_value"

View File

@@ -9,16 +9,12 @@ import (
const (
attrResponseType = attribute.Key("response_type")
attrResponseTypeWindowed = "windowed"
attrResponseTypeSampled = "sampled"
)
type moduleMetrics struct {
waterfallSpanLimit metric.Int64Gauge
waterfallRequestCount metric.Int64Counter
waterfallSpanCount metric.Int64Counter
flamegraphSpanLimit metric.Int64Gauge
flamegraphRequestCount metric.Int64Counter
}
func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
@@ -51,30 +47,9 @@ func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
errs = errors.Join(errs, err)
}
flamegraphSpanLimit, err := meter.Int64Gauge(
"signoz.traces.flamegraph.span.limit",
metric.WithDescription("The span count limit above which sampled flamegraph is returned instead of the full flamegraph."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
flamegraphRequestCount, err := meter.Int64Counter(
"signoz.traces.flamegraph.request.count",
metric.WithDescription("Total number of flamegraph requests, by response_type."),
metric.WithUnit("{request}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
return &moduleMetrics{
waterfallSpanLimit: spanLimit,
waterfallRequestCount: requestCount,
waterfallSpanCount: spanCount,
flamegraphSpanLimit: flamegraphSpanLimit,
flamegraphRequestCount: flamegraphRequestCount,
}, errs
}

View File

@@ -260,7 +260,7 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root1, root2}, spanMap)
spans, _ := trace.GetSelectedSpans([]string{"root1", "root2"}, "root1", 500, 5)
traceRespnose := spantypes.NewGettableWaterfallTrace(trace, spans, nil, false)
traceRespnose := spantypes.NewGettableWaterfallTrace(trace, spans, nil, false, nil)
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
assert.Equal(t, "svc-a", traceRespnose.RootServiceName, "metadata comes from first root")
@@ -567,7 +567,7 @@ func TestGetAllSpans(t *testing.T) {
)
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, nil)
spans := trace.GetAllSpans()
traceResponse := spantypes.NewGettableWaterfallTrace(trace, spans, nil, true)
traceResponse := spantypes.NewGettableWaterfallTrace(trace, spans, nil, true, nil)
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, "svc", traceResponse.RootServiceName)
assert.Equal(t, "root-op", traceResponse.RootServiceEntryPoint)

View File

@@ -5,19 +5,18 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
@@ -46,6 +47,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
"github.com/SigNoz/signoz/pkg/query-service/app/services"
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
"github.com/SigNoz/signoz/pkg/query-service/app/traces/tracedetail"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/constants"
@@ -896,6 +898,390 @@ func (r *ClickHouseReader) GetSpansForTrace(ctx context.Context, traceID string,
return searchScanResponses, nil
}
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadataCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetWaterfallSpansForTraceWithMetadataCache, error) {
cachedTraceData := new(model.GetWaterfallSpansForTraceWithMetadataCache)
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getWaterfallSpansForTraceWithMetadata", traceID}, "-"), cachedTraceData)
if err != nil {
r.logger.Debug("error in retrieving getWaterfallSpansForTraceWithMetadata cache", errorsV2.Attr(err), "traceID", traceID)
return nil, err
}
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
r.logger.Info("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache", "traceID", traceID)
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache, traceID: %s", traceID)
}
r.logger.Info("cache is successfully hit, applying cache for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, error) {
response := new(model.GetWaterfallSpansForTraceWithMetadataResponse)
var startTime, endTime, durationNano, totalErrorSpans, totalSpans uint64
var spanIdToSpanNodeMap = map[string]*model.Span{}
var traceRoots []*model.Span
var serviceNameToTotalDurationMap = map[string]uint64{}
var serviceNameIntervalMap = map[string][]tracedetail.Interval{}
var hasMissingSpans bool
cachedTraceData, err := r.GetWaterfallSpansForTraceWithMetadataCache(ctx, orgID, traceID)
if err == nil {
startTime = cachedTraceData.StartTime
endTime = cachedTraceData.EndTime
durationNano = cachedTraceData.DurationNano
spanIdToSpanNodeMap = cachedTraceData.SpanIdToSpanNodeMap
serviceNameToTotalDurationMap = cachedTraceData.ServiceNameToTotalDurationMap
traceRoots = cachedTraceData.TraceRoots
totalSpans = cachedTraceData.TotalSpans
totalErrorSpans = cachedTraceData.TotalErrorSpans
hasMissingSpans = cachedTraceData.HasMissingSpans
}
if err != nil {
r.logger.Info("cache miss for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, trace_id, has_error, kind, resource_string_service$$name, name, links as references, attributes_string, attributes_number, attributes_bool, resources_string, events, status_message, status_code_string, kind_string FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
if err != nil {
return nil, err
}
if len(searchScanResponses) == 0 {
return response, nil
}
totalSpans = uint64(len(searchScanResponses))
for _, item := range searchScanResponses {
ref := []model.OtelSpanRef{}
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
r.logger.Error("getWaterfallSpansForTraceWithMetadata: error unmarshalling references", errorsV2.Attr(err), "traceID", traceID)
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "getWaterfallSpansForTraceWithMetadata: error unmarshalling references %s", err.Error())
}
// merge attributes_number and attributes_bool to attributes_string
for k, v := range item.Attributes_bool {
item.Attributes_string[k] = fmt.Sprintf("%v", v)
}
for k, v := range item.Attributes_number {
item.Attributes_string[k] = strconv.FormatFloat(v, 'f', -1, 64)
}
for k, v := range item.Resources_string {
item.Attributes_string[k] = v
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getWaterfallSpansForTraceWithMetadata: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
jsonItem := model.Span{
SpanID: item.SpanID,
TraceID: item.TraceID,
ServiceName: item.ServiceName,
Name: item.Name,
Kind: int32(item.Kind),
DurationNano: item.DurationNano,
HasError: item.HasError,
StatusMessage: item.StatusMessage,
StatusCodeString: item.StatusCodeString,
SpanKind: item.SpanKind,
References: ref,
Events: events,
TagMap: item.Attributes_string,
Children: make([]*model.Span, 0),
TimeUnixNano: startTimeUnixNano, // Store nanoseconds temporarily
}
// metadata calculation
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
endTime = (startTimeUnixNano + jsonItem.DurationNano)
}
if durationNano == 0 || jsonItem.DurationNano > durationNano {
durationNano = jsonItem.DurationNano
}
if jsonItem.HasError {
totalErrorSpans = totalErrorSpans + 1
}
// collect the intervals for service for execution time calculation
serviceNameIntervalMap[jsonItem.ServiceName] =
append(serviceNameIntervalMap[jsonItem.ServiceName], tracedetail.Interval{StartTime: jsonItem.TimeUnixNano, Duration: jsonItem.DurationNano, Service: jsonItem.ServiceName})
// append to the span node map
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
}
// traverse through the map and append each node to the children array of the parent node
// and add the missing spans
for _, spanNode := range spanIdToSpanNodeMap {
hasParentSpanNode := false
for _, reference := range spanNode.References {
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
hasParentSpanNode = true
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// insert the missing span
missingSpan := model.Span{
SpanID: reference.SpanId,
TraceID: spanNode.TraceID,
ServiceName: "",
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
Kind: 0,
DurationNano: spanNode.DurationNano,
HasError: false,
StatusMessage: "",
StatusCodeString: "",
SpanKind: "",
Events: make([]model.Event, 0),
Children: make([]*model.Span, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
traceRoots = append(traceRoots, &missingSpan)
hasMissingSpans = true
}
}
}
if !hasParentSpanNode && !tracedetail.ContainsWaterfallSpan(traceRoots, spanNode) {
traceRoots = append(traceRoots, spanNode)
}
}
// sort the trace roots to add missing spans at the right order
sort.Slice(traceRoots, func(i, j int) bool {
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
return traceRoots[i].Name < traceRoots[j].Name
}
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
})
serviceNameToTotalDurationMap = tracedetail.CalculateServiceTime(serviceNameIntervalMap)
// TODO: set the span data (model.GetWaterfallSpansForTraceWithMetadataCache) in cache here
// removed existing cache usage since it was not getting used due to this bug https://github.com/SigNoz/engineering-pod/issues/4648
// and was causing out of memory issues https://github.com/SigNoz/engineering-pod/issues/4638
}
processingPostCache := time.Now()
// When req.Limit is 0 (not set by the client), selectAllSpans is set to false
// preserving the old paged behaviour for backward compatibility
limit := min(req.Limit, tracedetail.MaxLimitToSelectAllSpans)
selectAllSpans := totalSpans <= uint64(limit)
var (
selectedSpans []*model.Span
uncollapsedSpans []string
rootServiceName, rootServiceEntryPoint string
)
if selectAllSpans {
selectedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetAllSpans(traceRoots)
} else {
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
}
r.logger.Info("getWaterfallSpansForTraceWithMetadata: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID)
// convert start timestamp to millis because right now frontend is expecting it in millis
for _, span := range selectedSpans {
span.TimeUnixNano = span.TimeUnixNano / 1000000
}
for serviceName, totalDuration := range serviceNameToTotalDurationMap {
serviceNameToTotalDurationMap[serviceName] = totalDuration / 1000000
}
response.Spans = selectedSpans
response.UncollapsedSpans = uncollapsedSpans // ignoring if all spans are returning
response.StartTimestampMillis = startTime / 1000000
response.EndTimestampMillis = endTime / 1000000
response.TotalSpansCount = totalSpans
response.TotalErrorSpansCount = totalErrorSpans
response.RootServiceName = rootServiceName
response.RootServiceEntryPoint = rootServiceEntryPoint
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
response.HasMissingSpans = hasMissingSpans
response.HasMore = !selectAllSpans
return response, nil
}
func (r *ClickHouseReader) GetFlamegraphSpansForTraceCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetFlamegraphSpansForTraceCache, error) {
cachedTraceData := new(model.GetFlamegraphSpansForTraceCache)
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getFlamegraphSpansForTrace", traceID}, "-"), cachedTraceData)
if err != nil {
r.logger.Debug("error in retrieving getFlamegraphSpansForTrace cache", errorsV2.Attr(err), "traceID", traceID)
return nil, err
}
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
r.logger.Info("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache", "traceID", traceID)
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache, traceID: %s", traceID)
}
r.logger.Info("cache is successfully hit, applying cache for getFlamegraphSpansForTrace", "traceID", traceID)
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error) {
trace := new(model.GetFlamegraphSpansForTraceResponse)
var startTime, endTime, durationNano uint64
var spanIdToSpanNodeMap = map[string]*model.FlamegraphSpan{}
// map[traceID][level]span
var selectedSpans = [][]*model.FlamegraphSpan{}
var traceRoots []*model.FlamegraphSpan
// get the trace tree from cache!
cachedTraceData, err := r.GetFlamegraphSpansForTraceCache(ctx, orgID, traceID)
if err == nil {
startTime = cachedTraceData.StartTime
endTime = cachedTraceData.EndTime
durationNano = cachedTraceData.DurationNano
selectedSpans = cachedTraceData.SelectedSpans
traceRoots = cachedTraceData.TraceRoots
}
if err != nil {
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
selectCols := "timestamp, duration_nano, span_id, trace_id, has_error, links as references, resource_string_service$$name, name, events"
if len(req.SelectFields) > 0 {
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
}
flamegraphQuery := fmt.Sprintf("SELECT %s FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", selectCols, r.TraceDB, r.traceTableName)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)
if err != nil {
return nil, err
}
if len(searchScanResponses) == 0 {
return trace, nil
}
for _, item := range searchScanResponses {
ref := []model.OtelSpanRef{}
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
r.logger.Error("Error unmarshalling references", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
jsonItem := model.FlamegraphSpan{
SpanID: item.SpanID,
TraceID: item.TraceID,
ServiceName: item.ServiceName,
Name: item.Name,
DurationNano: item.DurationNano,
HasError: item.HasError,
References: ref,
Events: events,
Children: make([]*model.FlamegraphSpan, 0),
}
if len(req.SelectFields) > 0 {
jsonItem.SetRequestedFields(item, req.SelectFields)
}
// metadata calculation
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
endTime = (startTimeUnixNano + jsonItem.DurationNano)
}
if durationNano == 0 || jsonItem.DurationNano > durationNano {
durationNano = jsonItem.DurationNano
}
jsonItem.TimeUnixNano = uint64(item.TimeUnixNano.UnixNano() / 1000000)
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
}
// traverse through the map and append each node to the children array of the parent node
// and add missing spans
for _, spanNode := range spanIdToSpanNodeMap {
hasParentSpanNode := false
for _, reference := range spanNode.References {
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
hasParentSpanNode = true
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// insert the missing spans
missingSpan := model.FlamegraphSpan{
SpanID: reference.SpanId,
TraceID: spanNode.TraceID,
ServiceName: "",
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
HasError: false,
Events: make([]model.Event, 0),
Children: make([]*model.FlamegraphSpan, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
traceRoots = append(traceRoots, &missingSpan)
}
}
}
if !hasParentSpanNode && !tracedetail.ContainsFlamegraphSpan(traceRoots, spanNode) {
traceRoots = append(traceRoots, spanNode)
}
}
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
// TODO: set the trace data (model.GetFlamegraphSpansForTraceCache) in cache here
// removed existing cache usage since it was not getting used due to this bug https://github.com/SigNoz/engineering-pod/issues/4648
// and was causing out of memory issues https://github.com/SigNoz/engineering-pod/issues/4638
}
processingPostCache := time.Now()
selectedSpansForRequest := selectedSpans
clientLimit := min(req.Limit, tracedetail.MaxLimitWithoutSampling)
totalSpanCount := tracedetail.GetTotalSpanCount(selectedSpans)
if totalSpanCount > uint64(clientLimit) {
// using trace start and end time if boundary ts are set to zero (or not set)
boundaryStart := max(timestamp.MilliToNano(req.BoundaryStartTS), startTime)
boundaryEnd := timestamp.MilliToNano(req.BoundaryEndTS)
if boundaryEnd == 0 {
boundaryEnd = endTime
}
selectedSpansForRequest = tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, boundaryStart, boundaryEnd)
}
r.logger.Debug("getFlamegraphSpansForTrace: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID, "totalSpans", totalSpanCount, "limit", clientLimit)
trace.Spans = selectedSpansForRequest
trace.StartTimestampMillis = startTime / 1000000
trace.EndTimestampMillis = endTime / 1000000
trace.HasMore = totalSpanCount > uint64(clientLimit)
return trace, nil
}
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {

Some files were not shown because too many files have changed in this diff Show More