Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
51036d6cc4 feat(public-dashboard): integrate v2 (Perses-spec) public dashboards (#12032)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(public-dashboard): detect v1 vs v2 schema for the public viewer

Anonymous public viewers have no feature flags, so the schema can't be read from
use_dashboard_v2. Probe the v2 model endpoint first and fall back to v1 only on the
'dashboard_invalid_data' (HTTP 501) schema-mismatch signal. Probing v2 first also stops
the v1 endpoint from serving v2 dashboards with un-redacted queries.

* feat(public-dashboard): fetch v2 public panel data by key

Adds a by-key fetcher over the anonymous /api/v2/public/dashboards/{id}/panels/{key}/query_range
endpoint (the generated client omits the startTime/endTime params) and a store-free
usePublicPanelQuery that mirrors usePanelQuery's PanelQueryData shape. No variables and no
pagination — the public endpoint supports neither.

* feat(public-dashboard): render v2 public dashboards read-only

Adds a read-only v2 viewer that reuses the authenticated V2 panel renderers
(PanelHeader with hideActions, PanelBody, panel registry) and the pure layoutsToSections
util, with a forked read-only grid. The public page branches on the resolved schema:
v1 keeps the existing container, v2 renders the new viewer. Dashboard variables are not
rendered — the public endpoint does not substitute them.

* feat(public-dashboard): match the standard auto-refresh control

Replace the hand-rolled 'Off' select (which was styled inconsistently and clipped its
options) with a PublicAutoRefresh that mirrors the app's DateTimeSelectionV2 refresh cluster:
a grouped refresh button + auto-refresh popover (Auto Refresh checkbox + full interval list),
portal-rendered so nothing clips. It's prop-driven — the public viewer keeps managing its own
time window — so the container now tracks enabled + interval and exposes a manual refresh.
Also nudge the header-right gap 8→12px.

* feat(public-dashboard): declare v2 query_range params, drop the wrapper, address review

Declare startTime/endTime as query params on the v2 public query_range endpoint via
RequestQuery and regenerate the OpenAPI spec + orval client, so the generated
getPublicDashboardPanelQueryRangeV2 carries them. usePublicPanelQuery now calls the
generated fetcher directly and the hand-written wrapper is removed.

Also from review: drop the defensive panelDefinition guard so an unsupported kind
surfaces loudly, use lodash noop, and trim excessive comments across the v2 files.

* fix: bind query params from PublicWidgetQueryRangeParams

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-07-09 13:27:17 +00:00
Abhi kumar
f7bfd0eba6 fix(dashboard-v2): bug bash fixes III (#12041)
* fix(dashboard-v2): scroll the saved, cloned, or added panel/section into view

Saving a panel, closing the editor, cloning a panel, or adding a section
now reveals the affected panel/section instead of leaving the dashboard
scrolled to the top. Save resolves with the persisted panel id, a small
scroll-target store hands it to the grid, and a shared
scrollIntoViewWhenReady util polls for the optimistic DOM commit.

* fix(dashboard-v2): seed the metric unit per the kind's formatting controls

Replaces useMetricYAxisUnit with useSeedMetricUnit, driven by the kind's
Formatting controls (the same source buildPluginSpec reads): kinds with a
panel-wide unit seed formatting.unit; Table, which has none, seeds each
resolved value column's formatting.columnUnits instead. Column unit
selectors now also surface the metric-unit mismatch warning. Spec
read/write goes through a shared formattingSpec util.

* fix(dashboard-v2): stop grid placement from overlapping tall panels

findFreeSlot now probes the last-row candidate against every existing
item (mirroring the backend's overlap rejection) before placing there — a
tall panel from an earlier row can reach down into the last row — and
falls back to a fresh bottom row otherwise. Move-to-section reuses the
same primitive instead of always dropping to the bottom.

* fix(dashboard-v2): carry supported config across panel type switches

Switching visualization kind used to keep only formatting and thresholds,
dropping axes entirely and resetting legend/visualization/chartAppearance
to defaults. Each section seed now carries the old spec's fields the
target kind's controls declare: axes softMin/softMax/log scale, the time
preference, stacking/fillSpans, legend position, and chart appearance.
Unsupported fields (and legend customColors, keyed by series labels the
new kind may not reproduce) are still dropped.

* fix(dashboard-v2): keep context link editor alive on undecodable URL params

A literal % in a query value (e.g. ?search=95%) is not valid
percent-encoding, so decodeURIComponent threw URIError while seeding the
edit dialog and unmounted the page. Fall back to the raw string when a
key or value fails to decode.

* fix(dashboard-v2): don't auto-run the preview when a query is added

The query-type/datasource effect re-commits the draft on any structural
builder change, so adding a query immediately refetched the preview for
a still-empty query. Track the previous datasource list and skip the
commit when the change is a pure append — the new query commits on Run
Query as usual.

* fix(dashboard-v2): bootstrap variables and edit context on the editor route

A hard refresh (or direct URL) of the full-page editor mounts without
DashboardContainer or the variables bar, so the preview fetched with
unresolved variables and the store's edit context stayed cold. The
selection seeding moves out of useVariableSelection into a standalone
useSeedVariableSelection (URL → persisted store → default, with stale
URL entries pruned), which PanelEditorPage now mounts alongside
useResolvedVariables and the edit-context seed. The ?variables= param
is carried across dashboard ↔ editor navigation (withVariablesSearch)
so the selection survives the round trip (V1 parity).

* fix(dashboard-v2): carry thresholds and units onto table columns

Switching timeseries/number → table carried thresholds with an empty
columnName, which the save API rejects (TablePanelSpec.Thresholds[]
.ColumnName is required), and dropped the panel-wide unit entirely
(Table has no formatting.unit).

The renderer's keying rule is extracted as getAggregationColumnKey
(query name, or name.expression on multi-aggregation queries), and
getTableColumnKeys derives every enabled query's value-column keys
from the spec's queries alone, before any data exists — skipping
clickhouse_sql, whose columns key by the response's SQL alias.
Carried thresholds key onto the first derivable column and are
dropped when none exists instead of blocking the save; the panel-wide
unit fans out to every value column via formatting.columnUnits. The
carry is one-way: per-column units never seed a panel-wide unit back.

* chore: pr review changes

* chore: qb sticky header in view mode fix

* chore: pr review changes

* chore: pr review changes

- Unify panel/section reveal-scroll into one useScrollIntoView(id, ref, block)
  hook backed by a single scrollTargetId store (was two hooks + two ids).
- buildPluginSpec seeds return their slice (empty {}/[] when nothing to carry)
  instead of undefined; the omit-empty decision is centralized.
2026-07-09 12:44:44 +00:00
Nikhil Mantri
da250bfd3e fix: commented out container metrics to surface k8s.pod.start_time (#12042) 2026-07-09 12:09:53 +00:00
Nikhil Mantri
67007e2902 feat(infra-monitoring): Kubernetes Container Monitoring List API (#11946)
* chore: added types for containers

* chore: added querier query and constants

* chore: added helper queries

* chore: added wiring

* chore: added open api spec

* chore: endpoint and variable rename

* chore: use recency for container status.reason as well

* chore: pass orgID to getMetadata for containers (adapt to rebased main)

* chore: add metrics to the containers list for metadata and earliest time

* chore: corrected metrics list for metadata lookup

* chore: added changes to the checks API for new kube containers section

* chore: containers query modified

* chore: added integration tests

* chore: integration tests

* chore: goroutines in container monitoring

* chore: constants deduplication

* chore: inlined requests.Post call for this PR instead of wrapping in a function
2026-07-09 11:42:24 +00:00
119 changed files with 6164 additions and 1643 deletions

View File

@@ -4189,6 +4189,7 @@ components:
- namespaces
- clusters
- volumes
- kube_containers
type: string
InframonitoringtypesChecks:
properties:
@@ -4294,6 +4295,158 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesContainerCountsByReady:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesContainerCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
imagePullBackOff:
type: integer
oomKilled:
type: integer
running:
type: integer
terminated:
type: integer
unknown:
type: integer
waiting:
type: integer
required:
- running
- waiting
- terminated
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- unknown
type: object
InframonitoringtypesContainerReady:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesContainerRecord:
properties:
containerCountsByReady:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
containerCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
containerName:
type: string
cpu:
format: double
type: number
cpuLimitUtilization:
format: double
type: number
cpuRequestUtilization:
format: double
type: number
memory:
format: double
type: number
memoryLimitUtilization:
format: double
type: number
memoryRequestUtilization:
format: double
type: number
meta:
additionalProperties:
type: string
nullable: true
type: object
podUID:
type: string
ready:
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
restarts:
format: int64
type: integer
status:
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
required:
- podUID
- containerName
- status
- containerCountsByStatus
- ready
- containerCountsByReady
- restarts
- cpu
- cpuRequestUtilization
- cpuLimitUtilization
- memory
- memoryRequestUtilization
- memoryLimitUtilization
- meta
type: object
InframonitoringtypesContainerStatus:
enum:
- running
- waiting
- terminated
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- unknown
- no_data
type: string
InframonitoringtypesContainers:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
type: array
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -4968,6 +5121,32 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableContainers:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableDaemonSets:
properties:
end:
@@ -16099,6 +16278,86 @@ paths:
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/kube_containers:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
metrics: CPU usage (cores), CPU request/limit utilization, memory working
set, and memory request/limit utilization. Each container also reports health
signals from the k8s_cluster receiver: status (kubectl-style display status
derived from k8s.container.status.state + k8s.container.status.reason), restarts
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
stable across container restarts. Each container includes metadata attributes
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
as deployment/statefulset/daemonset/job). The response type is ''list'' for
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
with its current status and ready state) or ''grouped_list'' for custom groupBy
keys (each row aggregates containers in the group with per-status counts under
containerCountsByStatus, per-readiness counts under containerCountsByReady,
and restarts as the group sum). Status requires the optional k8s.container.status.state
and k8s.container.status.reason metrics; when either is missing, status is
omitted and a warning is returned while restarts and ready are still computed.
Supports filtering via a filter expression, custom groupBy, ordering by any
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
and pagination via offset/limit. Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
and restarts return -1 as a sentinel when no data is available for that field.'
operationId: ListContainers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesContainers'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/namespaces:
post:
deprecated: false
@@ -17909,6 +18168,16 @@ paths:
public dashboard. The panel is addressed by its key in spec.panels.
operationId: GetPublicDashboardPanelQueryRangeV2
parameters:
- in: query
name: startTime
required: false
schema:
type: string
- in: query
name: endTime
required: false
schema:
type: string
- in: path
name: id
required: true

View File

@@ -41,6 +41,7 @@ import type {
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2Params,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
*/
export const getPublicDashboardPanelQueryRangeV2 = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
method: 'GET',
params,
signal,
});
};
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
id,
key,
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
) => {
return [
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
...(params ? [params] : []),
] as const;
};
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
> = ({ signal }) =>
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
return {
queryKey,
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
{ id, key },
params,
options,
);
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
queryClient: QueryClient,
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
{
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
{ id, key },
params,
),
},
options,
);

View File

@@ -21,6 +21,7 @@ import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableContainersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
@@ -31,6 +32,7 @@ import type {
InframonitoringtypesPostableStatefulSetsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListContainers200,
ListDaemonSets200,
ListDeployments200,
ListHosts200,
@@ -548,6 +550,89 @@ export const useListJobs = <
> => {
return useMutation(getListJobsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const listContainers = (
inframonitoringtypesPostableContainersDTO?: BodyType<InframonitoringtypesPostableContainersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListContainers200>({
url: `/api/v2/infra_monitoring/kube_containers`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableContainersDTO,
signal,
});
};
export const getListContainersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
const mutationKey = ['listContainers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listContainers>>,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> }
> = (props) => {
const { data } = props ?? {};
return listContainers(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListContainersMutationResult = NonNullable<
Awaited<ReturnType<typeof listContainers>>
>;
export type ListContainersMutationBody =
| BodyType<InframonitoringtypesPostableContainersDTO>
| undefined;
export type ListContainersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const useListContainers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
return useMutation(getListContainersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring

View File

@@ -5577,6 +5577,7 @@ export enum InframonitoringtypesCheckTypeDTO {
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
kube_containers = 'kube_containers',
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
@@ -5856,6 +5857,174 @@ export interface InframonitoringtypesClustersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesContainerCountsByReadyDTO {
/**
* @type integer
*/
notReady: number;
/**
* @type integer
*/
ready: number;
}
export interface InframonitoringtypesContainerCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
terminated: number;
/**
* @type integer
*/
unknown: number;
/**
* @type integer
*/
waiting: number;
}
export enum InframonitoringtypesContainerReadyDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesContainerStatusDTO {
running = 'running',
waiting = 'waiting',
terminated = 'terminated',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
unknown = 'unknown',
no_data = 'no_data',
}
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
/**
* @type string
*/
containerName: string;
/**
* @type number
* @format double
*/
cpu: number;
/**
* @type number
* @format double
*/
cpuLimitUtilization: number;
/**
* @type number
* @format double
*/
cpuRequestUtilization: number;
/**
* @type number
* @format double
*/
memory: number;
/**
* @type number
* @format double
*/
memoryLimitUtilization: number;
/**
* @type number
* @format double
*/
memoryRequestUtilization: number;
/**
* @type object,null
*/
meta: InframonitoringtypesContainerRecordDTOMeta;
/**
* @type string
*/
podUID: string;
ready: InframonitoringtypesContainerReadyDTO;
/**
* @type integer
* @format int64
*/
restarts: number;
status: InframonitoringtypesContainerStatusDTO;
}
export interface InframonitoringtypesContainersDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
*/
records: InframonitoringtypesContainerRecordDTO[];
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6438,6 +6607,33 @@ export interface InframonitoringtypesPostableClustersDTO {
start: number;
}
export interface InframonitoringtypesPostableContainersDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableDaemonSetsDTO {
/**
* @type integer
@@ -11041,6 +11237,14 @@ export type ListJobs200 = {
status: string;
};
export type ListContainers200 = {
data: InframonitoringtypesContainersDTO;
/**
* @type string
*/
status: string;
};
export type ListNamespaces200 = {
data: InframonitoringtypesNamespacesDTO;
/**
@@ -11366,6 +11570,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
id: string;
key: string;
};
export type GetPublicDashboardPanelQueryRangeV2Params = {
/**
* @type string
* @description undefined
*/
startTime?: string;
/**
* @type string
* @description undefined
*/
endTime?: string;
};
export type GetPublicDashboardPanelQueryRangeV2200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
GET_ALL_LICENCES: 'GET_ALL_LICENCES',

View File

@@ -1031,19 +1031,26 @@ function ExplorerOptions({
/>
</div>
</Modal>
<ExportPanelContainer
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
onClose={onCancel(false)}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
centered
destroyOnClose
>
<ExportPanelContainer
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</Modal>
</div>
);
}

View File

@@ -179,8 +179,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
// Click the "New Dashboard" button
const newDashboardButton = screen.getByRole('button', {
name: /new dashboard/i,
});
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
@@ -227,12 +229,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -248,13 +251,14 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
// Wait for the selection to be made and the Export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
@@ -322,12 +326,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -343,13 +348,14 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
// Wait for the selection to be made and the Export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur

View File

@@ -1,74 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select, SelectProps } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { getSelectOptions } from './utils';
import styles from './ExportPanel.module.scss';
interface ExportDashboardSelectProps {
dashboards: Dashboard[];
value: string | null;
/** The picked dashboard, pinned as an option so its label survives a later search. */
selectedDashboard: Dashboard | null;
loading?: boolean;
disabled?: boolean;
onChange: (dashboardId: string) => void;
onSearch: (search: string) => void;
}
/**
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
* dropdown.
*/
function ExportDashboardSelect({
dashboards,
value,
selectedDashboard,
loading,
disabled,
onChange,
onSearch,
}: ExportDashboardSelectProps): JSX.Element {
const options = useMemo<SelectProps['options']>(() => {
const base = getSelectOptions(dashboards) ?? [];
if (
selectedDashboard &&
!base.some((option) => option.value === selectedDashboard.id)
) {
return [
{ label: selectedDashboard.data.title, value: selectedDashboard.id },
...base,
];
}
return base;
}, [dashboards, selectedDashboard]);
return (
<Select
className={styles.dashboardSelect}
placeholder="Select a dashboard"
showSearch
filterOption={false}
loading={loading}
disabled={disabled}
value={value}
onChange={onChange}
onSearch={onSearch}
data-testid="export-dashboard-select"
options={options}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
/>
);
}
ExportDashboardSelect.defaultProps = {
loading: false,
disabled: false,
};
export default ExportDashboardSelect;

View File

@@ -1,42 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--l2-foreground);
}
.dashboardSelect {
width: 100%;
}
.newDashboard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--l2-border);
}
.hint {
font-size: 13px;
color: var(--l2-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}

View File

@@ -1,155 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useMutation } from 'react-query';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
import { useExportDashboards } from 'hooks/dashboard/useExportDashboards';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import createDashboard from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import ExportDashboardSelect from './ExportDashboardSelect';
import styles from './ExportPanel.module.scss';
import { ExportPanelProps } from '.';
import {
DashboardSelect,
NewDashboardButton,
SelectWrapper,
Title,
Wrapper,
} from './styles';
import { filterOptions, getSelectOptions } from './utils';
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
/** Controlled open state of the dialog. */
open: boolean;
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
onClose: () => void;
}
/**
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
*/
function ExportPanelContainer({
isLoading,
onExport,
open,
onClose,
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
// Track the object, not just the id, so export survives a search that narrows it out.
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard | null>(
null,
);
const [searchText, setSearchText] = useState('');
const [dashboardId, setDashboardId] = useState<string | null>(null);
const {
dashboards,
data,
isLoading: isAllDashboardsLoading,
isFetching: isDashboardsFetching,
} = useExportDashboards(searchText);
refetch,
} = useGetAllDashboard();
const { create: createNewDashboard, isLoading: createDashboardLoading } =
useCreateExportDashboard({
title: t('new_dashboard_title', { ns: 'dashboard' }),
onCreated: (dashboard) => onExport(dashboard, true),
const { showErrorModal } = useErrorModal();
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
useMutation(createDashboard, {
onSuccess: (data) => {
if (data.data) {
onExport(data?.data, true);
}
refetch();
},
onError: (error) => {
showErrorModal(error as APIError);
},
});
// Reset on close so each open starts fresh (the dialog stays mounted).
useEffect(() => {
if (!open) {
setSelectedDashboard(null);
setSearchText('');
}
}, [open]);
const handleSelect = useCallback(
(dashboardId: string): void => {
setSelectedDashboard(
dashboards.find(({ id }) => id === dashboardId) ?? null,
);
},
[dashboards],
);
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
const handleExportClick = useCallback((): void => {
onExport(selectedDashboard, false);
}, [selectedDashboard, onExport]);
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
);
const isExportDisabled =
isAllDashboardsLoading || !selectedDashboard || isLoading;
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
},
[setDashboardId],
);
const handleNewDashboard = useCallback(async () => {
try {
await createNewDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
} catch (error) {
showErrorModal(error as APIError);
}
}, [createNewDashboard, t, showErrorModal]);
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Add to dashboard"
testId="export-panel-dialog"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={onClose}
testId="export-panel-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
loading={isLoading}
disabled={isExportDisabled}
onClick={handleExportClick}
testId="export-panel-export"
>
Add to dashboard
</Button>
</div>
}
>
<div className={styles.body}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Select a dashboard
</Typography.Text>
<ExportDashboardSelect
dashboards={dashboards}
value={selectedDashboard?.id ?? null}
selectedDashboard={selectedDashboard}
loading={isDashboardsFetching}
disabled={isAllDashboardsLoading || createDashboardLoading}
onChange={handleSelect}
onSearch={setSearchText}
/>
</div>
<Wrapper direction="vertical">
<Title>Export Panel</Title>
<div className={styles.newDashboard}>
<Typography.Text className={styles.hint}>
Or create a new dashboard with this panel
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<Plus size={14} />}
loading={createDashboardLoading}
disabled={createDashboardLoading}
onClick={createNewDashboard}
testId="export-panel-new-dashboard"
>
New dashboard
</Button>
</div>
</div>
</DialogWrapper>
<SelectWrapper direction="horizontal">
<DashboardSelect
placeholder="Select Dashboard"
options={options}
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>
<Button
type="primary"
loading={isLoading}
disabled={isDisabled}
onClick={handleExportClick}
>
Export
</Button>
</SelectWrapper>
<Typography>
Or create dashboard with this panel -
<NewDashboardButton
disabled={createDashboardLoading}
loading={createDashboardLoading}
type="link"
onClick={handleNewDashboard}
>
New Dashboard
</NewDashboardButton>
</Typography>
</Wrapper>
);
}
ExportPanelContainer.defaultProps = {
isLoading: false,
};
export default ExportPanelContainer;

View File

@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportPanelContainer from './ExportPanelContainer';
function ExportPanel({
isLoading,
onExport,
query,
}: ExportPanelProps): JSX.Element {
const [isExport, setIsExport] = useState<boolean>(false);
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
}, []);
const onCancel = (value: boolean) => (): void => {
onModalToggle(value);
};
return (
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={query}
isLoading={isLoading}
onExport={onExport}
/>
</Modal>
);
}
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
}
ExportPanel.defaultProps = { isLoading: false };
export default ExportPanel;

View File

@@ -0,0 +1,34 @@
import { FunctionComponent } from 'react';
import { Button, Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
Select,
)<SelectProps>`
width: 100%;
`;
export const SelectWrapper = styled(Space)`
width: 100%;
margin-bottom: 1rem;
.ant-space-item:first-child {
width: 100%;
max-width: 20rem;
}
`;
export const Wrapper = styled(Space)`
width: 100%;
`;
export const NewDashboardButton = styled(Button)`
&&& {
padding: 0 0.125rem;
}
`;
export const Title = styled(Typography.Text)`
font-size: 1rem;
`;

View File

@@ -6,3 +6,11 @@ export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
label: data.title,
value: id,
}));
export const filterOptions: SelectProps['filterOption'] = (
input,
options,
): boolean =>
(options?.label?.toString() ?? '')
?.toLowerCase()
.includes(input.toLowerCase());

View File

@@ -34,7 +34,6 @@ import {
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -53,6 +52,7 @@ import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import LogsActionsContainer from './LogsActionsContainer';
@@ -76,7 +76,6 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -287,7 +286,7 @@ function LogsExplorerViewsContainer({
dashboardName: dashboard?.data?.title,
});
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query: exportDefaultQuery,
panelType: panelTypeParam,
dashboardId: dashboard.id,
@@ -296,12 +295,7 @@ function LogsExplorerViewsContainer({
safeNavigate(dashboardEditView);
},
[
safeNavigate,
exportDefaultQuery,
selectedPanelType,
getExportToDashboardLink,
],
[safeNavigate, exportDefaultQuery, selectedPanelType],
);
useEffect(() => {

View File

@@ -14,7 +14,6 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
@@ -36,6 +35,7 @@ import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
@@ -63,7 +63,6 @@ function Explorer(): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
@@ -279,7 +278,7 @@ function Explorer(): JSX.Element {
};
}
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
@@ -288,7 +287,7 @@ function Explorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
[exportDefaultQuery, safeNavigate, yAxisUnit],
);
const splitedQueries = useMemo(

View File

@@ -1,93 +0,0 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const TITLE = 'My dashboard';
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCreateExportDashboard', () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(v1Dashboard));
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith(
expect.objectContaining({ id: 'v2-new', data: { title: TITLE } }),
),
);
expect(mockCreateV2).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 'v6',
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -1,120 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
dashboards: [
{
id: 'v2-1',
name: 'V2 Dashboard',
spec: { display: { name: 'V2 Dashboard' } },
},
],
},
},
isLoading: false,
isFetching: false,
refetch: v2Refetch,
});
});
describe('useExportDashboards', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([v1Dashboard, v1Other]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([v1Dashboard]);
});
it('returns the V2 list normalized to the dashboard shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
expect.objectContaining({ id: 'v2-1', data: { title: 'V2 Dashboard' } }),
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
expect.anything(),
);
});
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,45 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
widgetId: 'w1',
};
describe('useGetExportToDashboardLink', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
expect(link).not.toContain('widgetId');
expect(link).toContain('compositeQuery=');
});
});

View File

@@ -0,0 +1,94 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
PublicDashboardSchema,
useGetResolvedPublicDashboard,
} from '../useGetResolvedPublicDashboard';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardDataV2: jest.fn(),
}));
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
// error code; anything else must NOT trigger the v1 fallback.
const schemaMismatchError = {
isAxiosError: true,
response: {
status: 501,
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
},
};
describe('useGetResolvedPublicDashboard', () => {
beforeEach(() => {
mockV2.mockReset();
mockV1.mockReset();
});
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
expect(mockV1).not.toHaveBeenCalled();
});
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
mockV2.mockRejectedValue(schemaMismatchError);
mockV1.mockResolvedValue({
httpStatusCode: 200,
data: { dashboard: { data: { title: 'v1' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
});
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
mockV2.mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { error: { code: 'internal' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockV1).not.toHaveBeenCalled();
});
it('does not fetch without an id', () => {
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
expect(mockV2).not.toHaveBeenCalled();
expect(mockV1).not.toHaveBeenCalled();
});
});

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
interface UseCreateExportDashboardParams {
title: string;
/** Receives the created dashboard (normalized) for the caller to navigate/export. */
onCreated: (dashboard: Dashboard) => void;
}
interface UseCreateExportDashboardResult {
create: () => void;
isLoading: boolean;
}
/**
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to a `Dashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
(error: unknown): void => showErrorModal(error as APIError),
[showErrorModal],
);
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated(data.data);
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
}),
{
onSuccess: (created) => {
onCreated({
id: created.data.id,
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
locked: false,
data: { title },
} as Dashboard);
},
onError,
},
);
const create = useCallback((): void => {
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -1,88 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
export interface UseExportDashboardsResult {
dashboards: Dashboard[];
/** First load only — disables the picker until there are options. */
isLoading: boolean;
/** Any fetch incl. a search refetch — drives the picker spinner. */
isFetching: boolean;
refetch: () => void;
}
function toDashboard(
item: DashboardtypesListedDashboardForUserV2DTO,
): Dashboard {
return {
id: item.id,
createdAt: item.createdAt ?? '',
updatedAt: item.updatedAt ?? '',
createdBy: item.createdBy ?? '',
updatedBy: item.updatedBy ?? '',
locked: item.locked,
data: { title: item.spec.display?.name || item.name },
};
}
function filterByTitle(dashboards: Dashboard[], search: string): Dashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
(dashboard.data.title ?? '').toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
const term = search.trim();
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/**
* Flag-aware, search-filtered source for the "Add to dashboard" picker. V2 searches
* server-side (debounced); V1 filters its already-complete list in memory.
*/
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<Dashboard[]>(
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(toDashboard)
: filterByTitle(v1.data?.data ?? [], search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
widgetId: string;
}
/**
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor (`/dashboard/:id/panel/new`); V1 uses the legacy new-widget link. Keeps the V1
* `generateExportToDashboardLink` signature (V2 ignores `widgetId` — the id is made on save).
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -0,0 +1,57 @@
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { AxiosError, isAxiosError } from 'axios';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorV2Resp } from 'types/api';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
export enum PublicDashboardSchema {
V1 = 'v1',
V2 = 'v2',
}
export type ResolvedPublicDashboard =
| {
schema: PublicDashboardSchema.V2;
data: DashboardtypesGettablePublicDashboardDataV2DTO;
}
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
function isV2SchemaMismatch(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const { response } = error as AxiosError<ErrorV2Resp>;
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
}
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
async function resolvePublicDashboard(
id: string,
): Promise<ResolvedPublicDashboard> {
try {
const v2 = await getPublicDashboardDataV2({ id });
return { schema: PublicDashboardSchema.V2, data: v2.data };
} catch (error) {
if (!isV2SchemaMismatch(error)) {
throw error;
}
const v1 = await getPublicDashboardDataAPI({ id });
return { schema: PublicDashboardSchema.V1, data: v1.data };
}
}
export const useGetResolvedPublicDashboard = (
id: string,
): UseQueryResult<ResolvedPublicDashboard, Error> =>
useQuery<ResolvedPublicDashboard, Error>({
queryFn: () => resolvePublicDashboard(id),
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
enabled: !!id,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
buildExportPanelLink,
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
@@ -35,56 +31,4 @@ describe('newPanelRoute', () => {
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
).toBeNull();
});
describe('buildExportPanelLink', () => {
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const parseLink = (
link: string,
): { path: string; params: URLSearchParams } => {
const [path, search] = link.split('?');
return { path, params: new URLSearchParams(search) };
};
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
[PANEL_TYPES.LIST, 'signoz/ListPanel'],
])('maps export panel type %s to kind %s', (panelType, expectedKind) => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType,
query,
});
const { path, params } = parseLink(link);
expect(path).toBe('/dashboard/dash-1/panel/new');
expect(params.get('panelKind')).toBe(expectedKind);
expect(parseNewPanelKind(NEW_PANEL_ID, `?${params.toString()}`)).toBe(
expectedKind,
);
});
it('carries the query as a decodable compositeQuery param', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
});
it('falls back to TimeSeries for a panel type with no V2 kind', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.EMPTY_WIDGET,
query,
});
expect(parseLink(link).params.get('panelKind')).toBe(
'signoz/TimeSeriesPanel',
);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=&layoutIndex=…`); the real id is generated on save.
// New (unsaved) panels share a fixed id segment, carrying kind + target section
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
// id is generated on save.
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
@@ -43,31 +37,6 @@ export function parseNewPanelKind(
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
}
/**
* New-panel editor link that exports an explorer query into a V2 dashboard: maps panel
* type → kind (falls back to TimeSeries) and carries the raw `Query` as `compositeQuery`,
* encoded as the V1 link so `useGetCompositeQueryParam` reads it identically. Conversion
* happens in the editor; no `layoutIndex`, so exports land in the first section.
*/
export function buildExportPanelLink({
dashboardId,
panelType,
query,
}: {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
}): string {
const kind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? 'signoz/TimeSeriesPanel';
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(query))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,10 @@ export interface UseGetQueryRangeV5Args {
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
* APIError — that happens later at the display boundary), so inspect it at the axios level.
*/
function retryUnlessClientError(failureCount: number, error: Error): boolean {
export function retryUnlessClientError(
failureCount: number,
error: Error,
): boolean {
if (isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
return false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import {
generatePath,
Redirect,
@@ -7,13 +7,15 @@ import {
} from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
@@ -22,7 +24,9 @@ import {
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { buildNewPanelSeed } from './newPanelSeed';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
import styles from './PanelEditorPage.module.scss';
/**
@@ -41,35 +45,45 @@ function PanelEditorPage(): JSX.Element {
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
const { dashboard, isLoading, isError, error } =
const { dashboard, isLoading, isError, error, refetch } =
useDashboardFetch(dashboardId);
// Derived here (not from the store) because the editor route doesn't mount
// DashboardContainer, so the store's edit context may be cold on a direct URL.
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
const { isEditable, isLocked, canEditDashboard, editDisabledReason } =
useDashboardEditGuard(dashboard);
// On a refresh/direct URL this route is the only mount, so seed the edit
// context the way DashboardContainer does — during render, so the subtree's
// first render already sees the id (useDashboardFetchRequired throws without it).
const setEditContext = useDashboardStore((s) => s.setEditContext);
if (dashboard?.id) {
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard,
refetch,
});
}
// No variables bar on this route: seed the selection and publish the resolved
// payload so the preview and context links get variable values after a refresh.
useSeedVariableSelection(dashboard);
useResolvedVariables(dashboard);
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
// An explorer "Add to Dashboard" export rides the query in `compositeQuery` (V1
// parity). Captured once at mount: the editor rewrites `compositeQuery` in the URL
// as the user edits, and re-reading it would churn the draft (its reset target and
// dirty baseline live in the initially-loaded panel).
const exportCompositeQuery = useGetCompositeQueryParam();
const exportCompositeQueryRef = useRef(exportCompositeQuery);
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up (seeded from the exported query when present).
// Persisted (with a real id) only on save.
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);
const existingPanel = dashboard?.spec.panels[panelId];
const panel = useMemo(() => {
if (newKind) {
// `kind` may be coerced from `newKind` (e.g. a ClickHouse query into List).
const { kind, pluginSpec, queries } = buildNewPanelSeed(
return createDefaultPanel(
newKind,
exportCompositeQueryRef.current,
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultQueries(newKind),
);
return createDefaultPanel(kind, pluginSpec, queries);
}
if (!existingPanel) {
return undefined;
@@ -84,16 +98,11 @@ function PanelEditorPage(): JSX.Element {
const backToDashboard = useCallback((): void => {
// Carry only dashboard params; drop editor-only URL state (chiefly
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
const params = new URLSearchParams();
const variables = new URLSearchParams(search).get(QueryParams.variables);
if (variables) {
params.set(QueryParams.variables, variables);
}
const query = params.toString();
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${
query ? `?${query}` : ''
}`,
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
'',
search,
)}`,
);
}, [safeNavigate, dashboardId, search]);

View File

@@ -1,128 +0,0 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
import { buildNewPanelSeed } from './newPanelSeed';
jest.mock('../DashboardContainer/queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/utils/buildDefaultQueries', () => ({
buildDefaultQueries: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/utils/buildPluginSpec', () => ({
buildPluginSpec: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
isQueryTypeSupported: jest.fn(),
}));
const mockToPerses = toPerses as jest.Mock;
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const mockIsQueryTypeSupported = isQueryTypeSupported as jest.Mock;
const DEFAULT_QUERIES = [{ kind: 'default' }];
const CONVERTED_QUERIES = [{ kind: 'converted' }];
const BASE_SPEC = { visualization: { foo: 1 } };
const withUnit = [{ kind: SectionKind.Formatting, controls: { unit: true } }];
const withoutUnit = [
{ kind: SectionKind.Formatting, controls: { decimals: true } },
];
const q = (extra: Partial<Query> = {}): Query =>
({ queryType: 'builder', ...extra }) as unknown as Query;
describe('buildNewPanelSeed', () => {
beforeEach(() => {
jest.clearAllMocks();
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
mockIsQueryTypeSupported.mockReturnValue(true);
});
it('uses the kind default seed when there is no exported query', () => {
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', null);
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(DEFAULT_QUERIES);
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
expect(mockToPerses).not.toHaveBeenCalled();
});
it('converts the exported query into perses queries', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q());
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
});
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
'signoz/ListPanel',
q({ queryType: EQueryType.CLICKHOUSE }),
);
expect(seed.kind).toBe('signoz/TablePanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
});
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
'signoz/ListPanel',
q({ queryType: EQueryType.PROM }),
);
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
});
it('falls back to the default seed when conversion yields nothing runnable', () => {
mockToPerses.mockReturnValue([]);
const seed = buildNewPanelSeed('signoz/ListPanel', q());
expect(seed.queries).toBe(DEFAULT_QUERIES);
});
it('preserves the exported unit when the kind supports a unit', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q({ unit: 'ms' }));
expect(seed.pluginSpec).toStrictEqual({
...BASE_SPEC,
formatting: { unit: 'ms' },
});
});
it('drops the unit for a kind without a unit control', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetPanelDefinition.mockReturnValue({ sections: withoutUnit });
const seed = buildNewPanelSeed('signoz/TablePanel', q({ unit: 'ms' }));
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
});
});

View File

@@ -1,86 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../DashboardContainer/Panels/types/panelKind';
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import {
buildPluginSpec,
type SeededPluginSpec,
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
interface NewPanelSeed {
/** Effective kind to create — may differ from the requested one (see below). */
kind: PanelKind;
queries: DashboardtypesQueryDTO[];
pluginSpec: SeededPluginSpec;
}
/** Whether a kind's Formatting section exposes a `unit` control (Table/List don't). */
function kindSupportsUnit(kind: PanelKind): boolean {
return getPanelDefinition(kind).sections.some(
(section) =>
section.kind === SectionKind.Formatting &&
'controls' in section &&
section.controls.unit === true,
);
}
/**
* Effective kind for an export: the requested kind, or — when it can't hold the query's
* language (only List, which is builder-only) — a compatible one so the query isn't
* dropped: PromQL → TimeSeries, ClickHouse → Table.
*/
function resolveSeededPanelKind(
requestedKind: PanelKind,
compositeQuery: Query | null,
): PanelKind {
if (
!compositeQuery ||
isQueryTypeSupported(requestedKind, compositeQuery.queryType)
) {
return requestedKind;
}
return compositeQuery.queryType === EQueryType.PROM
? 'signoz/TimeSeriesPanel'
: 'signoz/TablePanel';
}
/**
* Seed for a new panel from the explorer "Add to Dashboard" export: resolve the effective
* kind, convert the exported `compositeQuery` to perses queries, and carry a unit-bearing
* kind's explorer `unit`. Falls back to the kind's default seed when there's no query or
* conversion yields nothing runnable.
*/
export function buildNewPanelSeed(
requestedKind: PanelKind,
compositeQuery: Query | null,
): NewPanelSeed {
const kind = resolveSeededPanelKind(requestedKind, compositeQuery);
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
if (!compositeQuery) {
return { kind, queries: buildDefaultQueries(kind), pluginSpec };
}
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
if (compositeQuery.unit && kindSupportsUnit(kind)) {
return {
kind,
queries,
pluginSpec: {
...pluginSpec,
formatting: { ...pluginSpec.formatting, unit: compositeQuery.unit },
},
};
}
return { kind, queries, pluginSpec };
}

View File

@@ -0,0 +1,23 @@
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
// holding borderless buttons split by a divider.
.refreshActions {
display: flex;
flex-direction: row;
border-radius: 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
.refreshButton {
display: flex;
border-right: 1px solid var(--l2-border);
}
:global(.ant-btn) {
display: flex;
padding: 4px 8px;
align-items: center;
box-shadow: none;
border: none;
background: transparent;
}
}

View File

@@ -0,0 +1,88 @@
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import { Button, Popover } from 'antd';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { popupContainer } from 'utils/selectPopupContainer';
import styles from './PublicAutoRefresh.module.scss';
// Reuse the app-wide auto-refresh popover styling.
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
interface PublicAutoRefreshProps {
enabled: boolean;
/** Selected interval key, e.g. `30s`. */
interval: string;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
onIntervalChange: (key: string) => void;
onRefresh: () => void;
}
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
// own time window, not Redux global time).
function PublicAutoRefresh({
enabled,
interval,
disabled = false,
onToggle,
onIntervalChange,
onRefresh,
}: PublicAutoRefreshProps): JSX.Element {
return (
<div className={styles.refreshActions}>
<div className={styles.refreshButton}>
<Button
icon={<RefreshCw size={16} />}
onClick={onRefresh}
title="Refresh"
data-testid="public-dashboard-refresh"
/>
</div>
<Popover
getPopupContainer={popupContainer}
placement="bottomRight"
rootClassName="auto-refresh-root"
trigger={['click']}
content={
<div className="auto-refresh-menu">
<Checkbox
onChange={(value): void => onToggle(value === true)}
value={enabled}
disabled={disabled}
className="auto-refresh-checkbox"
>
Auto Refresh
</Checkbox>
<Typography.Text disabled={disabled} className="refresh-interval-text">
Refresh Interval
</Typography.Text>
{refreshIntervalOptions
.filter((option) => option.label !== 'off')
.map((option) => (
<Button
type="text"
className="refresh-interval-btns"
key={option.label + option.value}
onClick={(): void => onIntervalChange(option.key)}
>
{option.label}
{option.key === interval && enabled && <Check size={14} />}
</Button>
))}
</div>
}
>
<Button
title="Set auto refresh"
data-testid="public-dashboard-auto-refresh"
>
<ChevronDown size={14} />
</Button>
</Popover>
</div>
);
}
export default PublicAutoRefresh;

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PublicAutoRefresh from '../PublicAutoRefresh';
const props = {
enabled: false,
interval: '30s',
onToggle: jest.fn(),
onIntervalChange: jest.fn(),
onRefresh: jest.fn(),
};
describe('PublicAutoRefresh', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the refresh and auto-refresh controls', () => {
render(<PublicAutoRefresh {...props} />);
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
expect(
screen.getByTestId('public-dashboard-auto-refresh'),
).toBeInTheDocument();
});
it('calls onRefresh when the refresh button is clicked', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
expect(props.onRefresh).toHaveBeenCalledTimes(1);
});
it('changes the interval from the menu', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
await userEvent.click(await screen.findByText('5 seconds'));
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
});
it('toggles auto-refresh from the menu', async () => {
render(<PublicAutoRefresh {...props} />);
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
await userEvent.click(await screen.findByRole('checkbox'));
expect(props.onToggle).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,70 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l0-background);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerLeft {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.brandLogo {
height: 24px;
width: 24px;
}
.brandName {
font-weight: 600;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-vanilla-400);
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.sectionTitle {
padding: 8px 4px 0;
font-weight: 600;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,159 @@
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import { layoutsToSections } from 'pages/DashboardPageV2/DashboardContainer/utils';
import { useMemo, useState } from 'react';
import { useInterval } from 'react-use';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
import styles from './PublicDashboardV2.module.scss';
interface PublicDashboardV2Props {
publicDashboardId: string;
data: DashboardtypesGettablePublicDashboardDataV2DTO;
}
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
// Variables aren't rendered — the public endpoint doesn't substitute them.
function PublicDashboardV2({
publicDashboardId,
data,
}: PublicDashboardV2Props): JSX.Element {
const { dashboard, publicDashboard } = data;
const sections = useMemo(
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
);
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
);
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
getStartTimeAndEndTimeFromTimeRange(
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
),
);
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
const handleTimeChange = (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
): void => {
if (dateTimeRange) {
setSelectedTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else if (interval !== 'custom') {
const { maxTime, minTime } = GetMinMax(interval);
setSelectedTimeRange({
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
});
}
setSelectedTimeRangeLabel(interval as string);
};
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
const refreshIntervalMs = useMemo(
() =>
autoRefreshEnabled
? refreshIntervalOptions.find(
(option) => option.key === autoRefreshInterval,
)?.value || 0
: 0,
[autoRefreshEnabled, autoRefreshInterval],
);
useInterval(
() => handleTimeChange(selectedTimeRangeLabel as Time),
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
);
const handleRefresh = (): void =>
handleTimeChange(selectedTimeRangeLabel as Time);
const startMs = selectedTimeRange.startTime * 1000;
const endMs = selectedTimeRange.endTime * 1000;
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<div className={styles.brand}>
<img src={signozBrandLogoUrl} alt="SigNoz" className={styles.brandLogo} />
<Typography className={styles.brandName}>SigNoz</Typography>
</div>
<Typography.Text className={styles.title}>
{dashboard?.spec?.display?.name}
</Typography.Text>
</div>
{isTimeRangeEnabled && (
<div className={styles.headerRight}>
<PublicAutoRefresh
enabled={autoRefreshEnabled}
interval={autoRefreshInterval}
disabled={isAutoRefreshPaused}
onToggle={setAutoRefreshEnabled}
onIntervalChange={setAutoRefreshInterval}
onRefresh={handleRefresh}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
isModalTimeSelection
modalSelectedInterval={selectedTimeRangeLabel as Time}
disableUrlSync
showRecentlyUsed={false}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
</div>
)}
</div>
<div className={styles.content}>
{sections.map((section) => (
<section key={section.id} className={styles.section}>
{section.title && (
<Typography.Text className={styles.sectionTitle}>
{section.title}
</Typography.Text>
)}
<PublicSectionGrid
items={section.items}
publicDashboardId={publicDashboardId}
startMs={startMs}
endMs={endMs}
isVisible
/>
</section>
))}
</div>
</div>
);
}
export default PublicDashboardV2;

View File

@@ -0,0 +1,10 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
}

View File

@@ -0,0 +1,78 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { noop } from 'lodash-es';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
import styles from './PublicPanel.module.scss';
interface PublicPanelProps {
panel: DashboardtypesPanelDTO;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** True once the panel is on screen — gates the fetch. */
isVisible?: boolean;
}
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
syncMode: DashboardCursorSync.None,
};
// Read-only v2 public panel: reuses the V2 header/body renderers with interactions disabled.
function PublicPanel({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
isVisible,
}: PublicPanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled: isVisible !== false,
});
return (
<div className={styles.panel} data-panel-root={panelKey}>
<PanelHeader
panelId={panelKey}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelKey}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={noop}
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
enableDrillDown={false}
/>
</div>
);
}
export default PublicPanel;

View File

@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
import PublicPanel from '../PublicPanel';
jest.mock('../../hooks/usePublicPanelQuery', () => ({
usePublicPanelQuery: jest.fn(),
}));
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
() => ({
__esModule: true,
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
() => ({
__esModule: true,
default: ({
enableDrillDown,
panelId,
}: {
enableDrillDown?: boolean;
panelId: string;
}): JSX.Element => (
<div
data-testid="panel-body"
data-drilldown={String(!!enableDrillDown)}
data-panel-id={panelId}
/>
),
}),
);
const mockQuery = usePublicPanelQuery as jest.Mock;
const queryResult = {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isLoading: false,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
};
const timeseriesPanel = {
kind: 'Panel',
spec: {
display: { name: 'panel-1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const commonProps = {
panelKey: 'p1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
};
describe('PublicPanel', () => {
beforeEach(() => {
mockQuery.mockReset();
mockQuery.mockReturnValue(queryResult);
});
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
expect(screen.getByTestId('panel-header')).toHaveAttribute(
'data-hide-actions',
'true',
);
const body = screen.getByTestId('panel-body');
expect(body).toHaveAttribute('data-drilldown', 'false');
expect(body).toHaveAttribute('data-panel-id', 'p1');
});
it('fetches by panel key and time', () => {
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
panelKey: 'p1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
}),
);
});
it('gates the fetch when off screen', () => {
render(
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
);
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
});

View File

@@ -0,0 +1,66 @@
import type { GridItem } from 'pages/DashboardPageV2/DashboardContainer/utils';
import GridLayout, { type Layout, WidthProvider } from 'react-grid-layout';
import PublicPanel from '../PublicPanel/PublicPanel';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface PublicSectionGridProps {
items: GridItem[];
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** True once the section is on screen — forwarded to gate panel fetches. */
isVisible?: boolean;
}
// Fixed (non-editable) grid for one section of a v2 public dashboard.
function PublicSectionGrid({
items,
publicDashboardId,
startMs,
endMs,
isVisible,
}: PublicSectionGridProps): JSX.Element {
const layout: Layout[] = items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
static: true,
}));
return (
<ResponsiveGridLayout
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={layout}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
// Empty cell for an orphan layout item (panel id missing from the map).
<div key={item.id}>
{item.panel && (
<PublicPanel
panel={item.panel}
panelKey={item.id}
publicDashboardId={publicDashboardId}
startMs={startMs}
endMs={endMs}
isVisible={isVisible}
/>
)}
</div>
))}
</ResponsiveGridLayout>
);
}
export default PublicSectionGrid;

View File

@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import PublicDashboardV2 from '../PublicDashboardV2';
const mockGrid = jest.fn();
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockGrid(props);
return <div data-testid="public-section-grid" />;
},
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="datetime-selection" />,
}));
jest.mock('../PublicAutoRefresh/PublicAutoRefresh', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="auto-refresh" />,
}));
function buildData(
timeRangeEnabled: boolean,
): DashboardtypesGettablePublicDashboardDataV2DTO {
return {
dashboard: {
schemaVersion: 'v6',
spec: {
display: { name: 'My V2 Dashboard' },
layouts: [
{
kind: 'Grid',
spec: {
display: { title: 'Section A' },
items: [
{
x: 0,
y: 0,
width: 6,
height: 6,
content: { $ref: '#/spec/panels/p1' },
},
],
},
},
],
panels: {
p1: {
kind: 'Panel',
spec: {
display: { name: 'p1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
},
},
variables: [],
},
},
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
}
describe('PublicDashboardV2', () => {
beforeEach(() => mockGrid.mockReset());
it('renders the dashboard title, section title, and a grid per section', () => {
render(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
);
expect(screen.getByText('My V2 Dashboard')).toBeInTheDocument();
expect(screen.getByText('Section A')).toBeInTheDocument();
expect(screen.getByTestId('public-section-grid')).toBeInTheDocument();
const gridProps = mockGrid.mock.calls[0][0];
expect(gridProps.publicDashboardId).toBe('pub-1');
expect(gridProps.items).toHaveLength(1);
expect(gridProps.items[0].id).toBe('p1');
expect(typeof gridProps.startMs).toBe('number');
expect(typeof gridProps.endMs).toBe('number');
// Times are handed to the endpoint in milliseconds.
expect(gridProps.endMs).toBeGreaterThan(gridProps.startMs);
});
it('shows the time controls only when the publisher enabled the time range', () => {
const { rerender } = render(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
);
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
rerender(
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(false)} />,
);
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,106 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { usePublicPanelQuery } from '../usePublicPanelQuery';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardPanelQueryRangeV2: jest.fn(),
}));
const mockFetch = getPublicDashboardPanelQueryRangeV2 as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A timeseries panel with a single runnable builder query (non-metrics signal → runnable).
const panel = {
kind: 'Panel',
spec: {
display: { name: 'panel-1' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { visualization: {} } },
queries: [
{
kind: 'time_series',
spec: {
name: 'A',
plugin: {
kind: 'signoz/BuilderQuery',
spec: { name: 'A', signal: 'logs', legend: 'My legend' },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const args = {
panel,
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,
endMs: 2000,
};
describe('usePublicPanelQuery', () => {
beforeEach(() => mockFetch.mockReset());
it('fetches by panel key + time and exposes the response as PanelQueryData', async () => {
mockFetch.mockResolvedValue({
status: 'success',
data: { type: 'time_series', data: { results: [] } },
});
const { result } = renderHook(() => usePublicPanelQuery(args), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockFetch).toHaveBeenCalledWith(
{ id: 'pub-1', key: 'panel-1' },
{ startTime: '1000', endTime: '2000' },
expect.anything(),
);
expect(result.current.data.response?.status).toBe('success');
expect(result.current.data.legendMap).toStrictEqual({ A: 'My legend' });
expect(result.current.data.requestPayload?.start).toBe(1000);
expect(result.current.data.requestPayload?.end).toBe(2000);
// The public endpoint has no paging support.
expect(result.current.pagination).toBeUndefined();
});
it('does not fetch without a public dashboard id', () => {
renderHook(() => usePublicPanelQuery({ ...args, publicDashboardId: '' }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when the panel has no runnable queries', () => {
const emptyPanel = {
kind: 'Panel',
spec: {
display: { name: 'empty' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
renderHook(() => usePublicPanelQuery({ ...args, panel: emptyPanel }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not fetch when disabled', () => {
renderHook(() => usePublicPanelQuery({ ...args, enabled: false }), {
wrapper,
});
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,136 @@
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
buildQueryRangeRequest,
extractLegendMap,
hasRunnableQueries,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import type {
PanelQueryData,
PanelPagination,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
/** Gate the fetch (default true). */
enabled?: boolean;
}
// Same shape as `usePanelQuery` so the V2 renderers consume it unchanged.
export interface UsePublicPanelQueryResult {
data: PanelQueryData;
isLoading: boolean;
isFetching: boolean;
isPreviousData: boolean;
error: Error | null;
refetch: () => void;
cancelQuery: () => void;
/** Always undefined — the public endpoint has no paging (#5557). */
pagination?: PanelPagination;
}
/**
* Fetches one v2 public panel by key + time. The public endpoint holds the query server-side
* (no body, variables, or paging); we still build the request DTO locally so the renderers get
* the `requestPayload`/`legendMap` they need — it is not sent.
*/
export function usePublicPanelQuery({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
const visualization =
pluginSpec && 'visualization' in pluginSpec
? pluginSpec.visualization
: undefined;
const fillGaps = Boolean(
visualization && 'fillSpans' in visualization && visualization.fillSpans,
);
// For the renderers only — not sent to the server.
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries,
panelType,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, panelType, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
// Redacted payloads are identical across panels — key on panel + time to avoid cache collisions.
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_WIDGET_DATA,
publicDashboardId,
panelKey,
startMs,
endMs,
],
[publicDashboardId, panelKey, startMs, endMs],
);
const response = useQuery<GetPublicDashboardPanelQueryRangeV2200, Error>({
queryKey,
queryFn: ({ signal }) =>
getPublicDashboardPanelQueryRangeV2(
{ id: publicDashboardId, key: panelKey },
{ startTime: String(startMs), endTime: String(endMs) },
signal,
),
enabled: enabled && runnable && !!publicDashboardId && !!panelKey,
retry: retryUnlessClientError,
});
const data = useMemo<PanelQueryData>(
() => ({ response: response.data, requestPayload, legendMap }),
[response.data, requestPayload, legendMap],
);
const queryClient = useQueryClient();
const cancelQuery = useCallback((): void => {
void queryClient.cancelQueries(queryKey);
}, [queryClient, queryKey]);
return {
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,
cancelQuery,
pagination: undefined,
};
}

View File

@@ -0,0 +1,31 @@
import dayjs from 'dayjs';
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
const UNIT_TO_DAYJS = {
m: 'minutes',
h: 'hours',
d: 'days',
w: 'weeks',
} as const;
// Relative range (`30m`/`6h`/`7d`/`1w`) → `{ startTime, endTime }` in unix seconds; default 30m.
export function getStartTimeAndEndTimeFromTimeRange(timeRange: string): {
startTime: number;
endTime: number;
} {
const match = timeRange.match(CUSTOM_TIME_REGEX);
if (match) {
const timeValue = parseInt(match[1] as string, 10);
const unit = UNIT_TO_DAYJS[match[2] as keyof typeof UNIT_TO_DAYJS];
return {
startTime: dayjs().subtract(timeValue, unit).unix(),
endTime: dayjs().unix(),
};
}
return {
startTime: dayjs().subtract(30, 'minutes').unix(),
endTime: dayjs().unix(),
};
}

View File

@@ -1,11 +1,15 @@
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
import {
PublicDashboardSchema,
useGetResolvedPublicDashboard,
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
import { Frown } from '@signozhq/icons';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
import PublicDashboardV2 from './PublicDashboardV2/PublicDashboardV2';
import './PublicDashboard.styles.scss';
@@ -14,27 +18,28 @@ function PublicDashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const {
data: publicDashboardData,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardData(dashboardId || '');
data: resolved,
isLoading,
isFetching,
isError,
} = useGetResolvedPublicDashboard(dashboardId || '');
const isLoading =
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
const isError = isErrorPublicDashboardData;
const isBusy = isLoading || isFetching;
return (
<div className="public-dashboard-page">
{publicDashboardData && (
{resolved?.schema === PublicDashboardSchema.V2 && (
<PublicDashboardV2 publicDashboardId={dashboardId} data={resolved.data} />
)}
{resolved?.schema === PublicDashboardSchema.V1 && (
<PublicDashboardContainer
publicDashboardId={dashboardId}
publicDashboardData={publicDashboardData}
publicDashboardData={{ httpStatusCode: 200, data: resolved.data }}
/>
)}
{isError && !isLoading && (
{isError && !isBusy && (
<div className="public-dashboard-error-container">
<div className="perilin-bg" />

View File

@@ -759,18 +759,19 @@ describe('TracesExplorer -', () => {
expect(createDashboardBtn).toBeInTheDocument();
fireEvent.click(createDashboardBtn);
const createDashboardModal = (await screen.findByRole(
'dialog',
)) as HTMLElement;
await expect(screen.findByText('Export Panel')).resolves.toBeInTheDocument();
const createDashboardModal = document.querySelector(
'.ant-modal-content',
) as HTMLElement;
expect(createDashboardModal).toBeInTheDocument();
// assert modal content
expect(
within(createDashboardModal).getByTestId('export-panel-new-dashboard'),
within(createDashboardModal).getByText('Select Dashboard'),
).toBeInTheDocument();
expect(
within(createDashboardModal).getByTestId('export-panel-export'),
within(createDashboardModal).getByText('New Dashboard'),
).toBeInTheDocument();
});

View File

@@ -27,7 +27,6 @@ import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/config
import QuerySection from 'container/TracesExplorer/QuerySection';
import TableView from 'container/TracesExplorer/TableView';
import TracesView from 'container/TracesExplorer/TracesView';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -47,6 +46,7 @@ import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import {
explorerViewToPanelType,
getExplorerViewFromUrl,
@@ -144,7 +144,6 @@ function TracesExplorer(): JSX.Element {
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -232,7 +231,7 @@ function TracesExplorer(): JSX.Element {
dashboardName: dashboard?.data?.title,
});
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
@@ -241,13 +240,7 @@ function TracesExplorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
[exportDefaultQuery, panelType, safeNavigate, options],
);
useShareBuilderUrl({ defaultValue: defaultQuery });

View File

@@ -466,6 +466,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Summary: "Get query range result (v2)",
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
Request: nil,
RequestQuery: new(dashboardtypes.PublicWidgetQueryRangeParams),
RequestContentType: "",
Response: new(querybuildertypesv5.QueryRangeResponse),
ResponseContentType: "application/json",

View File

@@ -48,6 +48,25 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/kube_containers", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListContainers),
handler.OpenAPIDef{
ID: "ListContainers",
Tags: []string{"inframonitoring"},
Summary: "List Kubernetes Containers for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableContainers),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Containers),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListNodes),
handler.OpenAPIDef{

View File

@@ -418,7 +418,13 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
params := new(dashboardtypes.PublicWidgetQueryRangeParams)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, params.StartTime, params.EndTime)
if err != nil {
render.Error(rw, err)
return

View File

@@ -49,17 +49,18 @@ var (
// checkSpecs is the single lookup table the module consults for a type's
// readiness contract. Every CheckType value must have an entry here.
var checkSpecs = map[inframonitoringtypes.CheckType]checkSpec{
inframonitoringtypes.CheckTypeHosts: hostsSpec,
inframonitoringtypes.CheckTypeProcesses: processesSpec,
inframonitoringtypes.CheckTypePods: podsSpec,
inframonitoringtypes.CheckTypeNodes: nodesSpec,
inframonitoringtypes.CheckTypeDeployments: deploymentsSpec,
inframonitoringtypes.CheckTypeDaemonsets: daemonsetsSpec,
inframonitoringtypes.CheckTypeStatefulsets: statefulsetsSpec,
inframonitoringtypes.CheckTypeJobs: jobsSpec,
inframonitoringtypes.CheckTypeNamespaces: namespacesSpec,
inframonitoringtypes.CheckTypeClusters: clustersSpec,
inframonitoringtypes.CheckTypeVolumes: volumesSpec,
inframonitoringtypes.CheckTypeHosts: hostsSpec,
inframonitoringtypes.CheckTypeProcesses: processesSpec,
inframonitoringtypes.CheckTypePods: podsSpec,
inframonitoringtypes.CheckTypeNodes: nodesSpec,
inframonitoringtypes.CheckTypeDeployments: deploymentsSpec,
inframonitoringtypes.CheckTypeDaemonsets: daemonsetsSpec,
inframonitoringtypes.CheckTypeStatefulsets: statefulsetsSpec,
inframonitoringtypes.CheckTypeJobs: jobsSpec,
inframonitoringtypes.CheckTypeNamespaces: namespacesSpec,
inframonitoringtypes.CheckTypeClusters: clustersSpec,
inframonitoringtypes.CheckTypeVolumes: volumesSpec,
inframonitoringtypes.CheckTypeKubeContainers: kubeContainersSpec,
}
// Per-type specs. Every metric and attribute is spelled out in its own spec
@@ -100,6 +101,42 @@ var processesSpec = checkSpec{
},
}
var kubeContainersSpec = checkSpec{
Buckets: []checkComponentBucket{
{
Component: componentKubeletStatsReceiver,
DefaultMetrics: []string{
"container.cpu.usage",
"container.memory.working_set",
},
OptionalMetrics: []string{
"k8s.container.cpu_request_utilization",
"k8s.container.cpu_limit_utilization",
"k8s.container.memory_request_utilization",
"k8s.container.memory_limit_utilization",
},
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{
"k8s.container.restarts",
"k8s.container.ready",
},
OptionalMetrics: []string{
"k8s.container.status.state",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
Component: componentK8sAttributesProcessor,
RequiredAttrs: []string{"k8s.pod.uid", "k8s.container.name"},
DocumentationLink: docLinkK8sAttributesProcessor,
},
},
}
var podsSpec = checkSpec{
Buckets: []checkComponentBucket{
{

View File

@@ -0,0 +1,808 @@
package implinframonitoring
import (
"context"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
// buildContainerRecords assembles the page records, merging kubeletstats
// metrics (A-F from the querier) with the k8scluster health signals (status,
// restarts, ready). In list mode (isContainerRowInGroupBy=true) each group is a
// single container, so exactly one status/ready bucket is 1 and the single
// Status/Ready fields are derived from it; otherwise they stay NoData and only
// the per-group counts are populated.
func buildContainerRecords(
isContainerNameAndPodUIDInGroupBy bool,
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
statusCounts map[string]containerStatusCounts,
restartCounts map[string]int64,
readyCounts map[string]containerReadyCounts,
) []inframonitoringtypes.ContainerRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.ContainerRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
record := inframonitoringtypes.ContainerRecord{ // initialize with default values
PodUID: labels[podUIDAttrKey],
ContainerName: labels[containerNameAttrKey],
Status: inframonitoringtypes.ContainerStatusNoData,
Ready: inframonitoringtypes.ContainerReadyNoData,
Restarts: -1,
CPU: -1,
CPURequestUtilization: -1,
CPULimitUtilization: -1,
Memory: -1,
MemoryRequestUtilization: -1,
MemoryLimitUtilization: -1,
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.CPU = v
}
if v, exists := metrics["B"]; exists {
record.CPURequestUtilization = v
}
if v, exists := metrics["C"]; exists {
record.CPULimitUtilization = v
}
if v, exists := metrics["D"]; exists {
record.Memory = v
}
if v, exists := metrics["E"]; exists {
record.MemoryRequestUtilization = v
}
if v, exists := metrics["F"]; exists {
record.MemoryLimitUtilization = v
}
}
if statusCountsForGroup, ok := statusCounts[compositeKey]; ok {
record.ContainerCountsByStatus = containerStatusCountsToResponse(statusCountsForGroup)
// In list mode each group is one container; the count==1 bucket identifies the status.
if isContainerNameAndPodUIDInGroupBy {
switch {
case statusCountsForGroup.Running == 1:
record.Status = inframonitoringtypes.ContainerStatusRunning
case statusCountsForGroup.Waiting == 1:
record.Status = inframonitoringtypes.ContainerStatusWaiting
case statusCountsForGroup.Terminated == 1:
record.Status = inframonitoringtypes.ContainerStatusTerminated
case statusCountsForGroup.CrashLoopBackOff == 1:
record.Status = inframonitoringtypes.ContainerStatusCrashLoopBackOff
case statusCountsForGroup.ImagePullBackOff == 1:
record.Status = inframonitoringtypes.ContainerStatusImagePullBackOff
case statusCountsForGroup.ErrImagePull == 1:
record.Status = inframonitoringtypes.ContainerStatusErrImagePull
case statusCountsForGroup.CreateContainerConfigError == 1:
record.Status = inframonitoringtypes.ContainerStatusCreateContainerConfigError
case statusCountsForGroup.ContainerCreating == 1:
record.Status = inframonitoringtypes.ContainerStatusContainerCreating
case statusCountsForGroup.OOMKilled == 1:
record.Status = inframonitoringtypes.ContainerStatusOOMKilled
case statusCountsForGroup.Completed == 1:
record.Status = inframonitoringtypes.ContainerStatusCompleted
case statusCountsForGroup.Error == 1:
record.Status = inframonitoringtypes.ContainerStatusError
case statusCountsForGroup.ContainerCannotRun == 1:
record.Status = inframonitoringtypes.ContainerStatusContainerCannotRun
case statusCountsForGroup.Unknown == 1:
record.Status = inframonitoringtypes.ContainerStatusUnknown
}
}
}
// Restart count: container's own count (list mode) or group sum (grouped mode).
if restartCountForGroup, ok := restartCounts[compositeKey]; ok {
record.Restarts = restartCountForGroup
}
if readyCountsForGroup, ok := readyCounts[compositeKey]; ok {
record.ContainerCountsByReady = containerReadyCountsToResponse(readyCountsForGroup)
// In list mode each group is one container; exactly one of ready/not_ready is 1.
if isContainerNameAndPodUIDInGroupBy {
switch {
case readyCountsForGroup.Ready == 1:
record.Ready = inframonitoringtypes.ContainerReadyReady
case readyCountsForGroup.NotReady == 1:
record.Ready = inframonitoringtypes.ContainerReadyNotReady
}
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopContainerGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableContainers,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.ContainerNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ContainerNameAttrKey), nil
}
queryNamesForOrderBy := orderByToContainersQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newContainersTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range containerAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, containersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}
// getPerGroupContainerStatusCountsWithReqMetricChecks gates
// getPerGroupContainerStatusCounts on the required metrics being present. If
// either of containerStatusMetricNamesList (k8s.container.status.state /
// k8s.container.status.reason) has never been reported, it skips the query and
// returns a warning instead (the status derivation needs both). Otherwise it
// runs the query. The returned counts map is empty (never nil) when gated off.
func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]containerStatusCounts, *qbtypes.QueryWarnData, error) {
present, err := m.getMetricsExistence(ctx, containerStatusMetricNamesList)
if err != nil {
return nil, nil, err
}
var missing []string
for _, name := range containerStatusMetricNamesList {
if !present[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
warning := &qbtypes.QueryWarnData{
Message: fmt.Sprintf(
"Container status could not be computed: required metric(s) not found: %s. "+
"Enable the optional k8s.container.status.state and k8s.container.status.reason "+
"metrics in the k8s_cluster receiver to see container statuses.",
strings.Join(missing, ", "),
),
Url: docLinkK8sClusterReceiver,
}
return map[string]containerStatusCounts{}, warning, nil
}
counts, err := m.getPerGroupContainerStatusCounts(ctx, start, end, filter, groupBy, pageGroups)
if err != nil {
return nil, nil, err
}
return counts, nil, nil
}
// getPerGroupContainerStatusCounts computes per-group counts of distinct
// containers bucketed by their latest kubectl-style display status in window.
// Caller must ensure the required metrics exist
// (getPerGroupContainerStatusCountsWithReqMetricChecks).
//
// Row identity is (pod_uid, container_name). Pipeline:
//
// state_fps / container_state: current k8s.container.status.state per container
// (argMaxIf(state, unix_milli, value=1) — safe because a
// container always has exactly one active state).
// reason_fps / container_reason: current active k8s.container.status.reason per container
// (two-level: HAVING is_active=1 excludes stale reasons of
// recovered containers, then recency picks the latest).
// container_status: display status per container (reason > state fallback).
// countContainersPerStatus: per-group uniqExactIf over distinct (pod_uid, container_name).
//
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupContainerStatusCounts(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]containerStatusCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]containerStatusCounts{}, nil
}
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
// Built once; identical across the two fps CTEs (buildFilterClause hits the
// metadata store + parses the expression). AddWhereClause only reads it.
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
// ----- state_fps (carries groupBy cols) -----
stateFps := sqlbuilder.NewSelectBuilder()
stateFpsCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", stateFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", stateFps.Var(containerNameAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS state", stateFps.Var(containerStatusStateAttrKey)),
}
for _, key := range groupBy {
stateFpsCols = append(stateFpsCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", stateFps.Var(key.Name), quoteIdentifier(key.Name)),
)
}
stateFps.Select(stateFpsCols...)
stateFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
stateFps.Where(
stateFps.E("metric_name", containerStatusStateMetricName),
stateFps.GE("unix_milli", tsAdjustedStart),
stateFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
stateFps.AddWhereClause(filterClause)
}
stateFpsGroupBy := []string{"fingerprint", "pod_uid", "container_name", "state"}
for _, key := range groupBy {
stateFpsGroupBy = append(stateFpsGroupBy, quoteIdentifier(key.Name))
}
stateFps.GroupBy(stateFpsGroupBy...)
stateFpsSQL, stateFpsArgs := stateFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_state (current state per container; single-pass argMaxIf) -----
containerState := sqlbuilder.NewSelectBuilder()
containerStateCols := []string{
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
containerStateCols = append(containerStateCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
}
containerStateCols = append(containerStateCols,
fmt.Sprintf("argMaxIf(fps.state, samples.unix_milli, samples.%s = 1) AS state", valueCol),
)
containerState.Select(containerStateCols...)
containerState.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN state_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
containerState.Where(
containerState.E("samples.metric_name", containerStatusStateMetricName),
containerState.GE("samples.unix_milli", samplesStartMs),
containerState.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
containerState.GroupBy("fps.pod_uid", "fps.container_name")
containerStateSQL, containerStateArgs := containerState.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- reason_fps -----
reasonFps := sqlbuilder.NewSelectBuilder()
reasonFps.Select(
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", reasonFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", reasonFps.Var(containerNameAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", reasonFps.Var(containerStatusReasonAttrKey)),
)
reasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
reasonFps.Where(
reasonFps.E("metric_name", containerStatusReasonMetricName),
reasonFps.GE("unix_milli", tsAdjustedStart),
reasonFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
reasonFps.AddWhereClause(filterClause)
}
reasonFps.GroupBy("fingerprint", "pod_uid", "container_name", "reason")
reasonFpsSQL, reasonFpsArgs := reasonFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_reason -----
// Inner: latest value per (pod, container, reason) -> kills stale fingerprints
// from old container incarnations; keep only active (=1). Outer: the current
// incarnation's reason wins via recency (argMax by last_active_ms). We do NOT
// priority-rank reasons at container grain -- "worst wins" is a pod-level concept
// (aggregating multiple containers into one status); a single container has one
// reason at a time, and multiple only appear active via container.id churn on
// restart, where the latest (current) incarnation is the right answer.
reasonInner := sqlbuilder.NewSelectBuilder()
reasonInner.Select(
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
"fps.reason AS reason",
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS is_active", valueCol),
"max(samples.unix_milli) AS last_active_ms",
)
reasonInner.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
reasonInner.Where(
reasonInner.E("samples.metric_name", containerStatusReasonMetricName),
reasonInner.GE("samples.unix_milli", samplesStartMs),
reasonInner.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
reasonInner.GroupBy("fps.pod_uid", "fps.container_name", "fps.reason")
reasonInner.Having("is_active = 1")
reasonInnerSQL, reasonInnerArgs := reasonInner.BuildWithFlavor(sqlbuilder.ClickHouse)
containerReasonSQL := fmt.Sprintf(
"SELECT pod_uid, container_name, argMax(reason, last_active_ms) AS active_reason FROM (%s) GROUP BY pod_uid, container_name",
reasonInnerSQL,
)
// ----- container_status (display status per container) -----
// container reason > state fallback (running/terminated/waiting).
displayStatusExpr := `multiIf(
cr.active_reason != '', cr.active_reason,
st.state = 'running', 'Running',
st.state = 'terminated', 'Terminated',
st.state = 'waiting', 'Waiting',
'Unknown')`
containerStatusSelectCols := []string{
"st.pod_uid AS pod_uid",
"st.container_name AS container_name",
}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
containerStatusSelectCols = append(containerStatusSelectCols, fmt.Sprintf("st.%s AS %s", col, col))
}
containerStatusSelectCols = append(containerStatusSelectCols, displayStatusExpr+" AS display_status")
containerStatusSQL := fmt.Sprintf(
"SELECT %s FROM container_state AS st LEFT JOIN container_reason AS cr ON st.pod_uid = cr.pod_uid AND st.container_name = cr.container_name",
strings.Join(containerStatusSelectCols, ", "),
)
// ----- countContainersPerStatus (outer SELECT) -----
// Fixed status order; MUST match the containerStatusCounts assignment below.
statusCountCols := []string{
"uniqExactIf((pod_uid, container_name), display_status = 'Running') AS running_count",
"uniqExactIf((pod_uid, container_name), display_status = 'Waiting') AS waiting_count",
"uniqExactIf((pod_uid, container_name), display_status = 'Terminated') AS terminated_count",
"uniqExactIf((pod_uid, container_name), display_status = 'CrashLoopBackOff') AS crash_loop_back_off_count",
"uniqExactIf((pod_uid, container_name), display_status = 'ImagePullBackOff') AS image_pull_back_off_count",
"uniqExactIf((pod_uid, container_name), display_status = 'ErrImagePull') AS err_image_pull_count",
"uniqExactIf((pod_uid, container_name), display_status = 'CreateContainerConfigError') AS create_container_config_error_count",
"uniqExactIf((pod_uid, container_name), display_status = 'ContainerCreating') AS container_creating_count",
"uniqExactIf((pod_uid, container_name), display_status = 'OOMKilled') AS oom_killed_count",
"uniqExactIf((pod_uid, container_name), display_status = 'Completed') AS completed_count",
"uniqExactIf((pod_uid, container_name), display_status = 'Error') AS error_count",
"uniqExactIf((pod_uid, container_name), display_status = 'ContainerCannotRun') AS container_cannot_run_count",
"uniqExactIf((pod_uid, container_name), display_status = 'Unknown') AS unknown_count",
}
countSelectCols := make([]string, 0, len(groupBy)+len(statusCountCols))
countGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
countSelectCols = append(countSelectCols, col)
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols, statusCountCols...)
countSQL := fmt.Sprintf(
"SELECT %s FROM container_status GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
cteFragments := []string{
fmt.Sprintf("state_fps AS (%s)", stateFpsSQL),
fmt.Sprintf("container_state AS (%s)", containerStateSQL),
fmt.Sprintf("reason_fps AS (%s)", reasonFpsSQL),
fmt.Sprintf("container_reason AS (%s)", containerReasonSQL),
fmt.Sprintf("container_status AS (%s)", containerStatusSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
finalArgs := querybuilder.PrependArgs([][]any{
stateFpsArgs, containerStateArgs, reasonFpsArgs, reasonInnerArgs,
}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]containerStatusCounts)
for rows.Next() {
groupVals := make([]string, len(groupBy))
counts := make([]uint64, len(statusCountCols))
scanPtrs := make([]any, 0, len(groupBy)+len(statusCountCols))
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
for i := range counts {
scanPtrs = append(scanPtrs, &counts[i])
}
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = containerStatusCounts{
Running: int(counts[0]),
Waiting: int(counts[1]),
Terminated: int(counts[2]),
CrashLoopBackOff: int(counts[3]),
ImagePullBackOff: int(counts[4]),
ErrImagePull: int(counts[5]),
CreateContainerConfigError: int(counts[6]),
ContainerCreating: int(counts[7]),
OOMKilled: int(counts[8]),
Completed: int(counts[9]),
Error: int(counts[10]),
ContainerCannotRun: int(counts[11]),
Unknown: int(counts[12]),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// getPerGroupContainerRestartCounts computes the absolute container restart count
// per group from k8s.container.restarts (default-enabled, so no existence gate).
// In list mode (groupBy contains the row identity) each group is one container ->
// its restart count; in grouped mode it's the summed restarts across all
// containers in the group. argMax(value, unix_milli) per (pod, container) takes
// the current cumulative count from the latest incarnation, then sum.
//
// restart_fps: fp ↔ (pod_uid, container_name, groupBy cols) from time_series.
// container_restarts: INNER JOIN samples, latest restartCount per (pod, container).
// (outer): per-group sum(restart_count).
//
// Groups absent from the result map have no data (caller default).
func (m *module) getPerGroupContainerRestartCounts(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]int64, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]int64{}, nil
}
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
// ----- restart_fps (carries groupBy cols) -----
restartFps := sqlbuilder.NewSelectBuilder()
restartFpsCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", restartFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", restartFps.Var(containerNameAttrKey)),
}
for _, key := range groupBy {
restartFpsCols = append(restartFpsCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", restartFps.Var(key.Name), quoteIdentifier(key.Name)),
)
}
restartFps.Select(restartFpsCols...)
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
restartFps.Where(
restartFps.E("metric_name", containerRestartsMetricName),
restartFps.GE("unix_milli", tsAdjustedStart),
restartFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
restartFps.AddWhereClause(filterClause)
}
restartFpsGroupBy := []string{"fingerprint", "pod_uid", "container_name"}
for _, key := range groupBy {
restartFpsGroupBy = append(restartFpsGroupBy, quoteIdentifier(key.Name))
}
restartFps.GroupBy(restartFpsGroupBy...)
restartFpsSQL, restartFpsArgs := restartFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_restarts (latest cumulative count per container) -----
containerRestarts := sqlbuilder.NewSelectBuilder()
containerRestartsCols := []string{
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
}
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS restart_count", valueCol))
containerRestarts.Select(containerRestartsCols...)
containerRestarts.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
containerRestarts.Where(
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
containerRestarts.GE("samples.unix_milli", samplesStartMs),
containerRestarts.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
containerRestarts.GroupBy("fps.pod_uid", "fps.container_name")
containerRestartsSQL, containerRestartsArgs := containerRestarts.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- outer: per-group sum across containers -----
sumSelectCols := make([]string, 0, len(groupBy)+1)
sumGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
sumSelectCols = append(sumSelectCols, col)
sumGroupBy = append(sumGroupBy, col)
}
sumSelectCols = append(sumSelectCols, "sum(restart_count) AS total_restarts")
sumSQL := fmt.Sprintf(
"SELECT %s FROM container_restarts GROUP BY %s",
strings.Join(sumSelectCols, ", "),
strings.Join(sumGroupBy, ", "),
)
cteFragments := []string{
fmt.Sprintf("restart_fps AS (%s)", restartFpsSQL),
fmt.Sprintf("container_restarts AS (%s)", containerRestartsSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + sumSQL
finalArgs := querybuilder.PrependArgs([][]any{restartFpsArgs, containerRestartsArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]int64)
for rows.Next() {
groupVals := make([]string, len(groupBy))
var totalRestarts float64
scanPtrs := make([]any, 0, len(groupBy)+1)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
scanPtrs = append(scanPtrs, &totalRestarts)
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = int64(totalRestarts)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// getPerGroupContainerReadyCounts computes per-group counts of distinct
// containers bucketed by their latest readiness (k8s.container.ready, a single
// 0/1 gauge per container — default-enabled, so no existence gate). ready = latest
// value 1; not_ready = latest value < 1.
//
// ready_fps: fp ↔ (pod_uid, container_name, groupBy cols) from time_series.
// container_ready: INNER JOIN samples, latest ready value per (pod, container).
// (outer): per-group uniqExactIf over distinct (pod_uid, container_name).
//
// Groups absent from the result map have no data (caller default).
func (m *module) getPerGroupContainerReadyCounts(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]containerReadyCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]containerReadyCounts{}, nil
}
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
// ----- ready_fps (carries groupBy cols) -----
readyFps := sqlbuilder.NewSelectBuilder()
readyFpsCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", readyFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", readyFps.Var(containerNameAttrKey)),
}
for _, key := range groupBy {
readyFpsCols = append(readyFpsCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", readyFps.Var(key.Name), quoteIdentifier(key.Name)),
)
}
readyFps.Select(readyFpsCols...)
readyFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
readyFps.Where(
readyFps.E("metric_name", containerReadyMetricName),
readyFps.GE("unix_milli", tsAdjustedStart),
readyFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
readyFps.AddWhereClause(filterClause)
}
readyFpsGroupBy := []string{"fingerprint", "pod_uid", "container_name"}
for _, key := range groupBy {
readyFpsGroupBy = append(readyFpsGroupBy, quoteIdentifier(key.Name))
}
readyFps.GroupBy(readyFpsGroupBy...)
readyFpsSQL, readyFpsArgs := readyFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_ready (latest 0/1 per container) -----
containerReady := sqlbuilder.NewSelectBuilder()
containerReadyCols := []string{
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
containerReadyCols = append(containerReadyCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
}
containerReadyCols = append(containerReadyCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS ready_value", valueCol))
containerReady.Select(containerReadyCols...)
containerReady.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN ready_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
containerReady.Where(
containerReady.E("samples.metric_name", containerReadyMetricName),
containerReady.GE("samples.unix_milli", samplesStartMs),
containerReady.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
containerReady.GroupBy("fps.pod_uid", "fps.container_name")
containerReadySQL, containerReadyArgs := containerReady.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- outer: per-group ready / not_ready counts over distinct containers -----
countSelectCols := make([]string, 0, len(groupBy)+2)
countGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
countSelectCols = append(countSelectCols, col)
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols,
"uniqExactIf((pod_uid, container_name), ready_value = 1) AS ready",
"uniqExactIf((pod_uid, container_name), ready_value < 1) AS not_ready",
)
countSQL := fmt.Sprintf(
"SELECT %s FROM container_ready GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
cteFragments := []string{
fmt.Sprintf("ready_fps AS (%s)", readyFpsSQL),
fmt.Sprintf("container_ready AS (%s)", containerReadySQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
finalArgs := querybuilder.PrependArgs([][]any{readyFpsArgs, containerReadyArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]containerReadyCounts)
for rows.Next() {
groupVals := make([]string, len(groupBy))
var ready, notReady uint64
scanPtrs := make([]any, 0, len(groupBy)+2)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
scanPtrs = append(scanPtrs, &ready, &notReady)
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = containerReadyCounts{
Ready: int(ready),
NotReady: int(notReady),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

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