mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
8 Commits
issue_5601
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadcbfdb6d | ||
|
|
f8514482fa | ||
|
|
c629cc40dc | ||
|
|
da250bfd3e | ||
|
|
67007e2902 | ||
|
|
ff3ec133b4 | ||
|
|
0ea5399c15 | ||
|
|
f7aa3cee9c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,9 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
|
||||
// (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@@ -93,11 +95,17 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
|
||||
// form (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Contain Escape so it cancels the inline edit instead of bubbling up and
|
||||
// closing the host drawer/modal.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -15,12 +16,11 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
'--series-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span style={colorStyle}>
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
|
||||
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
|
||||
interface RecentDashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function Dashboards({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
@@ -26,33 +38,58 @@ export default function Dashboards({
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
|
||||
|
||||
// Fetch Dashboards
|
||||
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
|
||||
// selects; the inactive one stays disabled so it never fires.
|
||||
const {
|
||||
data: dashboardsList,
|
||||
isLoading: isDashboardListLoading,
|
||||
isError: isDashboardListError,
|
||||
} = useGetAllDashboard();
|
||||
data: v1List,
|
||||
isLoading: v1Loading,
|
||||
isError: v1Error,
|
||||
} = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
|
||||
const {
|
||||
data: v2List,
|
||||
isLoading: v2Loading,
|
||||
isError: v2Error,
|
||||
} = useListDashboardsForUserV2(
|
||||
{
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
},
|
||||
{ query: { enabled: isDashboardV2 } },
|
||||
);
|
||||
|
||||
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
|
||||
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
|
||||
|
||||
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
|
||||
if (isDashboardV2) {
|
||||
return (v2List?.data?.dashboards ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.spec?.display?.name ?? d.name,
|
||||
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
|
||||
}));
|
||||
}
|
||||
return [...(v1List?.data ?? [])]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.data.title,
|
||||
tags: d.data.tags ?? [],
|
||||
}));
|
||||
}, [isDashboardV2, v1List, v2List]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedDashboards = dashboardsList.data.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.updatedAt).getTime();
|
||||
const bUpdateAt = new Date(b.updatedAt).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
});
|
||||
|
||||
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
|
||||
}
|
||||
|
||||
setSortedDashboards(sortedDashboards.slice(0, 5));
|
||||
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -113,7 +150,7 @@ export default function Dashboards({
|
||||
event.stopPropagation();
|
||||
logEvent('Homepage: Dashboard clicked', {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: dashboard.data.title,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
@@ -143,12 +180,12 @@ export default function Dashboards({
|
||||
/>
|
||||
|
||||
<div className="alert-rule-item-name home-data-item-name">
|
||||
{dashboard.data.title}
|
||||
{dashboard.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item-description home-data-item-tag">
|
||||
{dashboard.data.tags?.map((tag) => (
|
||||
{dashboard.tags.map((tag) => (
|
||||
<Badge color="sienna" variant="outline" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export type Dimensions = {
|
||||
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
// Measure before paint so the first frame has real dimensions, not 0 (the
|
||||
// debounced observer below only catches up after paint → layout jump).
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
setSize({ width: node.clientWidth, height: node.clientHeight });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { type FocusEvent, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
@@ -90,12 +90,20 @@ function DashboardInfo({
|
||||
}
|
||||
};
|
||||
|
||||
// Clicking outside the editor commits, matching the input's Enter behaviour.
|
||||
// Guard against blurs that move focus to the Save/Cancel buttons within it.
|
||||
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
onCommit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.trigger {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
|
||||
import styles from './DashboardImagePicker.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The selected image — one of the base64 icon data-URIs.
|
||||
image: string;
|
||||
onChange: (value: string) => void;
|
||||
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
|
||||
// a name input); this component owns size / background / icon-only styling.
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
// Icon picker shared by the dashboard-details settings and the create-dashboard
|
||||
// modal so both choose from the same `Base64Icons` set.
|
||||
function DashboardImagePicker({
|
||||
image,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardImagePicker;
|
||||
@@ -25,38 +25,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The image-picker owns its size/background; here we only round the left edge so
|
||||
// it joins flush with the name input to its right.
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
@@ -43,30 +37,11 @@ function DashboardInfoForm({
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={onImageChange}
|
||||
triggerClassName={styles.dashboardImageInput}
|
||||
/>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
|
||||
@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
// `replace` fails when the image doesn't exist yet, so add it when the
|
||||
// dashboard has none (`add` creates or replaces the member). Key off the
|
||||
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
|
||||
ops.push(
|
||||
op(
|
||||
dashboard.image
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/image',
|
||||
updatedImage,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
description,
|
||||
updatedImage,
|
||||
image,
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -7,4 +7,9 @@
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
/* The dropdown portals to body; lift it above the query builder's antd
|
||||
* popups (1050) so it stays clickable, incl. inside the View modal.
|
||||
* TODO: remove after the antd -> @signozhq/ui migration
|
||||
**/
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function AddColumnDropdown({
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
|
||||
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
padding: 12px;
|
||||
// Top padding lives on the sticky tab nav instead, so the nav keeps its
|
||||
// breathing room both at rest and when pinned to the top.
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
@@ -24,6 +26,15 @@
|
||||
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);
|
||||
|
||||
&::before {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -29,18 +30,36 @@ jest.mock(
|
||||
() => ({
|
||||
flattenTimeSeries: jest.fn(),
|
||||
getTimeSeriesResults: jest.fn(() => []),
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
|
||||
() => ({ prepareScalarTables: jest.fn(() => []) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
|
||||
() => ({ preparePieData: jest.fn(() => []) }),
|
||||
);
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
|
||||
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
const mockPreparePie = preparePieData as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const PIE_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const HISTOGRAM_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves histogram panels via the time-series path', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
|
||||
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
{ label: 'b', defaultColor: 'color:b' },
|
||||
]);
|
||||
// The pie path must not run for a histogram panel.
|
||||
expect(mockPreparePie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves pie panels from their scalar slices, deduped by label', () => {
|
||||
mockPreparePie.mockReturnValue([
|
||||
{ label: 'x', color: 'c1' },
|
||||
{ label: 'y', color: 'c2' },
|
||||
{ label: 'x', color: 'c1' },
|
||||
]);
|
||||
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'x', defaultColor: 'c1' },
|
||||
{ label: 'y', defaultColor: 'c2' },
|
||||
]);
|
||||
// The time-series path must not run for a pie panel.
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
|
||||
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
|
||||
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
|
||||
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
|
||||
* order-preserving; empty until data arrives or for kinds without flat time-series data.
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -33,27 +22,15 @@ export function useLegendSeries(
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return useMemo(() => {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data.legendMap,
|
||||
);
|
||||
const builderQueries = getBuilderQueries(panel.spec.queries);
|
||||
|
||||
const byLabel = new Map<string, string>();
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, generateColor(label, palette));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
export interface LegendSeries {
|
||||
/** Resolved display label — the key `legend.customColors` is indexed by. */
|
||||
label: string;
|
||||
/** The series' auto-assigned color, shown when no override is set. */
|
||||
defaultColor: string;
|
||||
}
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
* label never resolves a second color.
|
||||
*/
|
||||
function buildLegendSeries(
|
||||
labels: readonly string[],
|
||||
colorFor: (label: string, index: number) => string,
|
||||
): LegendSeries[] {
|
||||
const byLabel = new Map<string, string>();
|
||||
labels.forEach((label, index) => {
|
||||
if (label && !byLabel.has(label)) {
|
||||
byLabel.set(label, colorFor(label, index));
|
||||
}
|
||||
});
|
||||
return Array.from(byLabel, ([label, defaultColor]) => ({
|
||||
label,
|
||||
defaultColor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap,
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
isDarkMode,
|
||||
});
|
||||
return buildLegendSeries(
|
||||
slices.map((slice) => slice.label),
|
||||
(_, index) => slices[index].color,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
const builderQueries = getBuilderQueries(queries);
|
||||
const series = flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap,
|
||||
);
|
||||
return buildLegendSeries(
|
||||
series.map((s) =>
|
||||
resolveSeriesLabelV5(
|
||||
s,
|
||||
builderQueries,
|
||||
getLabelName(s.labels, s.queryName, s.legend),
|
||||
),
|
||||
),
|
||||
(label) => generateColor(label, palette),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
@@ -15,6 +17,8 @@ interface NoDataProps {
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -29,19 +33,30 @@ function NoData({
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
panel,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
// The View modal's local extender wins; the global one only applies to a panel that
|
||||
// follows the ambient window (a fixed preference can't be widened by it).
|
||||
const hasFixedTimePreference = panel
|
||||
? panelHasFixedTimePreference(panel)
|
||||
: false;
|
||||
const activeExtend =
|
||||
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
? {
|
||||
label: activeExtend.actionLabel,
|
||||
onClick: activeExtend.extend,
|
||||
icon: <CalendarRange size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
@@ -24,6 +28,15 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal panel whose plugin spec carries the given time preference. */
|
||||
function panelWith(
|
||||
timePreference?: DashboardtypesTimePreferenceDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { spec: { visualization: { timePreference } } } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
@@ -119,4 +132,47 @@ describe('NoData', () => {
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the global extend action for a panel with a fixed time preference', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData
|
||||
onRetry={onRetry}
|
||||
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only Retry — extending the dashboard window can't widen a locked panel span.
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still offers the global extend action for a panel that follows the global window', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(
|
||||
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ function BarPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true },
|
||||
controls: { position: true, colors: true },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -143,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,7 @@ function NumberPanelRenderer({
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
panel={panel}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
|
||||
@@ -95,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
// (position + per-slice color overrides).
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -160,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
@@ -43,15 +43,7 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
// A per-panel time preference is surfaced as a header pill. `visualization` is
|
||||
// common to every plugin-spec variant — localized cast reads it without
|
||||
// narrowing on kind.
|
||||
const timePreference = (
|
||||
panel.spec.plugin.spec as
|
||||
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
|
||||
| undefined
|
||||
)?.visualization?.timePreference;
|
||||
const timeLabel = panelTimePreferenceLabel(timePreference);
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
@@ -89,6 +89,14 @@ function section(
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
|
||||
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
|
||||
// Untitled root plus two titled sections — exercises the multi-target submenu.
|
||||
const ROOT_AND_TWO_TITLED = [
|
||||
section(0, undefined),
|
||||
section(1, 'A'),
|
||||
section(2, 'B'),
|
||||
];
|
||||
// Just the free-flow root: an ungrouped board with no sections to move between.
|
||||
const ONLY_ROOT = [section(0, undefined)];
|
||||
|
||||
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
|
||||
// translation it drives is covered by buildCreateAlertUrl's own tests.
|
||||
@@ -111,7 +119,9 @@ const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
// Panel sits in a titled section with an untitled root present, so every
|
||||
// action — including "Move to section" (→ Dashboard root) — is available.
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
};
|
||||
|
||||
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
@@ -210,7 +220,7 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
@@ -220,8 +230,8 @@ describe('usePanelActionItems', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
// An untitled section only counts as the root at layoutIndex 0.
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
@@ -233,14 +243,77 @@ describe('usePanelActionItems', () => {
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
it('"Move to section" offers a single "Dashboard (root)" target', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
|
||||
});
|
||||
|
||||
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as {
|
||||
children: { onClick: () => void }[];
|
||||
};
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('an ungrouped panel (in the root) can move into each titled section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('a panel in a titled section can move to the root and the other titled sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; label: string }[] };
|
||||
// Root leads, then the other titled section — never the current one (A).
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual([
|
||||
'move-to-root',
|
||||
'move-2',
|
||||
]);
|
||||
expect(move.children.map((c) => c.label)).toStrictEqual([
|
||||
'Dashboard (root)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves between titled sections even when the board has no untitled root', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
}),
|
||||
);
|
||||
const move = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
@@ -249,47 +322,14 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
|
||||
it('hides "Move to section" when the board has no sections (only the root)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('"Move out of section" moves the panel to the untitled root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
const moveOut = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'move-to-root',
|
||||
);
|
||||
(moveOut as { onClick: () => void }).onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 1,
|
||||
toLayoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when the panel already sits in the root section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
|
||||
}),
|
||||
);
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
});
|
||||
|
||||
it('hides "Move out of section" when every section is titled (no root)', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).not.toContain('move-to-root');
|
||||
expect(itemKeys(result.current)).not.toContain('move');
|
||||
});
|
||||
|
||||
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
|
||||
@@ -312,7 +352,7 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
expect(result.current.deleteConfirm.open).toBe(false);
|
||||
});
|
||||
@@ -325,7 +365,7 @@ describe('usePanelActionItems', () => {
|
||||
(clone as { onClick: () => void }).onClick();
|
||||
expect(mockClonePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
layoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -74,6 +74,14 @@ function PanelHeader({
|
||||
[warning],
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide the entire header when there's no title, description, or status to show,
|
||||
* and the actions menu is suppressed (editor preview).
|
||||
*/
|
||||
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
.dialog[data-width] {
|
||||
max-width: 85%;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Modal } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
@@ -25,27 +33,48 @@ function ViewPanelModal({
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Dialog
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name} - (View mode)
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
<DialogContent
|
||||
ref={contentRef}
|
||||
position="center"
|
||||
width="extra-wide"
|
||||
className={styles.dialog}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
{open && panel && panelId && (
|
||||
<ConfigProvider
|
||||
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
|
||||
>
|
||||
<ViewPanelModalContent
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -44,6 +45,7 @@ function ViewPanelModalContent({
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
@@ -54,7 +56,17 @@ function ViewPanelModalContent({
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
@@ -119,6 +131,15 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={signal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
@@ -128,6 +149,7 @@ function ViewPanelModalContent({
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseViewPanelModeArgs {
|
||||
export interface UseViewPanelModeReturn {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Update the draft's spec in place (e.g. the List columns editor). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/**
|
||||
@@ -159,6 +161,7 @@ export function useViewPanelMode({
|
||||
|
||||
return {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { FolderInput, FolderOutput } from '@signozhq/icons';
|
||||
import { FolderInput } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { findRootSection, type DashboardSection } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
|
||||
// Matches the root option label in the New Panel picker (SectionPicker).
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
@@ -12,9 +15,11 @@ interface MoveItemsArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu plus a direct "Move out of section" to the
|
||||
* untitled root, shown only when the panel sits in a titled section and a root
|
||||
* section exists to receive it.
|
||||
* "Move to section" submenu listing every section the panel can move to — the
|
||||
* dashboard root (untitled top level, labelled "Dashboard (root)") plus every
|
||||
* titled section, minus the one the panel already sits in. Hidden entirely when
|
||||
* there is nowhere to move: an ungrouped board with no titled sections, or a
|
||||
* panel that is alone in the only section.
|
||||
*/
|
||||
export function buildMoveItems({
|
||||
sections,
|
||||
@@ -22,44 +27,38 @@ export function buildMoveItems({
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
|
||||
// Sections are already in layout order, so the root (index 0, when present)
|
||||
// naturally leads. Untitled non-root layouts are never a move target.
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
(section) =>
|
||||
section.layoutIndex !== currentLayoutIndex &&
|
||||
(section === rootSection || Boolean(section.title)),
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
children: targets.map((section) => {
|
||||
const isRoot = section === rootSection;
|
||||
return {
|
||||
key: isRoot ? 'move-to-root' : `move-${section.layoutIndex}`,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: section.layoutIndex,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = findRootSection(sections);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
@@ -41,12 +39,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
|
||||
|
||||
@@ -77,17 +69,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
// Untitled section — just the grid, no header chrome.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -98,7 +87,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
|
||||
@@ -20,3 +20,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
|
||||
.panelWrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import SectionGridItem from './SectionGridItem';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
@@ -12,8 +12,6 @@ const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
interface SectionGridProps {
|
||||
items: DashboardSection['items'];
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
}
|
||||
@@ -21,7 +19,6 @@ interface SectionGridProps {
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -61,10 +58,9 @@ function SectionGrid({
|
||||
// panel with no content.
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
<SectionGridItem
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useRef } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
rootMargin: '200px',
|
||||
};
|
||||
|
||||
interface SectionGridItemProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
|
||||
* passes it to the presentational Panel as `isVisible`, so a board of many panels
|
||||
* only fetches what's on screen.
|
||||
*/
|
||||
function SectionGridItem({
|
||||
panel,
|
||||
panelId,
|
||||
panelActions,
|
||||
}: SectionGridItemProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useIntersectionObserver(
|
||||
containerRef,
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
<Panel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGridItem;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -6,6 +5,7 @@ import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import { selectVariablesExpanded } from '../store/slices/collapseSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './AddVariableFull';
|
||||
import AddVariableIcon from './AddVariableIcon';
|
||||
@@ -45,10 +45,13 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -75,7 +78,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
onClick={(): void => setVariablesExpanded(dashboardId, !expanded)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
@@ -120,7 +123,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
{(expanded || hasOverflow) && (
|
||||
<span className={styles.moreButton}>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
|
||||
@@ -57,6 +57,8 @@ function ValueSelector({
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
maxTagCount={2}
|
||||
maxTagTextLength={20}
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
|
||||
@@ -1,74 +1,196 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStaleCheck } from '../useDashboardStaleCheck';
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
const mockUseIsMutating = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQuery: (...args: unknown[]): unknown => mockUseQuery(...args),
|
||||
useIsMutating: (): number => mockUseIsMutating(),
|
||||
useQueryClient: (): unknown => ({ getQueryData: jest.fn() }),
|
||||
}));
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/d1']),
|
||||
}));
|
||||
|
||||
function setServerUpdatedAt(updatedAt: string | undefined): void {
|
||||
mockUseQuery.mockReturnValue({ data: { data: { updatedAt } } });
|
||||
const mockGetDashboard = getDashboardV2 as jest.Mock;
|
||||
|
||||
const SPEC_A = { panels: { p1: {} } };
|
||||
const SPEC_B = { panels: { p1: {}, p2: {} } };
|
||||
|
||||
interface Content {
|
||||
spec?: unknown;
|
||||
tags?: unknown;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
// Defaults make loaded and server content identical unless a field is overridden.
|
||||
function withDefaults(content: Content): Record<string, unknown> {
|
||||
return { spec: SPEC_A, tags: [], locked: false, ...content };
|
||||
}
|
||||
|
||||
function loaded(
|
||||
updatedAt: string,
|
||||
content: Content = {},
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id: 'd1',
|
||||
updatedAt,
|
||||
...withDefaults(content),
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function setVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate the tab/window coming back with the given server copy. */
|
||||
async function comeBack(
|
||||
serverUpdatedAt: string,
|
||||
content: Content = {},
|
||||
via: 'visibilitychange' | 'focus' = 'visibilitychange',
|
||||
): Promise<void> {
|
||||
mockGetDashboard.mockResolvedValueOnce({
|
||||
data: { updatedAt: serverUpdatedAt, ...withDefaults(content) },
|
||||
});
|
||||
setVisibility('visible');
|
||||
await act(async () => {
|
||||
if (via === 'focus') {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
} else {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardStaleCheck', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsMutating.mockReturnValue(0);
|
||||
setVisibility('visible');
|
||||
});
|
||||
|
||||
it('prompts when the server copy is newer than the loaded one', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not fetch on first load — only when the tab/window returns', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
|
||||
await comeBack('2026-07-08T09:00:00Z');
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: 'd1' });
|
||||
});
|
||||
|
||||
it('does not prompt when the versions match', () => {
|
||||
setServerUpdatedAt('2026-07-08T09:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('also probes on window focus (returning from another app)', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', {}, 'focus');
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prompts when newer and the spec differs', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the lock state differs (external lock)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { locked: false }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { locked: true });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('prompts when newer and the tags differ', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(
|
||||
loaded('2026-07-08T09:00:00Z', { tags: [] }),
|
||||
jest.fn(),
|
||||
),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', {
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
});
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
|
||||
it('does NOT prompt when newer but content (spec/tags/locked) is unchanged', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
// updatedAt bumped but every content field matches — e.g. our own lock, already
|
||||
// reflected in the render cache.
|
||||
await comeBack('2026-07-08T10:00:00Z');
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', () => {
|
||||
it('does not prompt when the loaded copy is newer (own edit)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T10:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T09:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('does not prompt while a mutation is in flight (optimistic save)', async () => {
|
||||
mockUseIsMutating.mockReturnValue(1);
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
|
||||
it('does not probe while the tab is hidden', async () => {
|
||||
renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
setVisibility('hidden');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops prompting for a version once dismissed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('reload refetches and clears the dismissed state', () => {
|
||||
setServerUpdatedAt('2026-07-08T10:00:00Z');
|
||||
it('reload refetches and closes the prompt immediately', async () => {
|
||||
const refetch = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), refetch),
|
||||
);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
act(() => result.current.reload());
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.showPrompt).toBe(true);
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it('re-prompts when a newer, different version appears after a reload', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
|
||||
);
|
||||
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.showPrompt).toBe(false);
|
||||
|
||||
await comeBack('2026-07-08T10:05:00Z', { spec: { panels: { p3: {} } } });
|
||||
await waitFor(() => expect(result.current.showPrompt).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
|
||||
export interface PanelTimeWindow {
|
||||
@@ -65,6 +68,28 @@ const TIME_PREFERENCE_LABEL: Partial<
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's saved `visualization.timePreference`, or `undefined` when the kind has none. */
|
||||
export function getPanelTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): DashboardtypesTimePreferenceDTO | undefined {
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
if (pluginSpec && 'visualization' in pluginSpec) {
|
||||
return pluginSpec.visualization?.timePreference;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the panel is locked to a fixed relative window, so it ignores the dashboard's ambient time. */
|
||||
export function panelHasFixedTimePreference(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): boolean {
|
||||
const timePreference = getPanelTimePreference(panel);
|
||||
return (
|
||||
timePreference !== undefined &&
|
||||
timePreference !== DashboardtypesTimePreferenceDTO.global_time
|
||||
);
|
||||
}
|
||||
|
||||
export interface PanelTimePreferenceLabel {
|
||||
/** Compact pill label, e.g. `6h`. */
|
||||
short: string;
|
||||
|
||||
@@ -1,65 +1,92 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIsMutating, useQuery, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getDashboardV2,
|
||||
getGetDashboardV2QueryKey,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIsMutating } from 'react-query';
|
||||
|
||||
interface UseDashboardStaleCheck {
|
||||
// Server copy has a newer updatedAt than the loaded one, and not yet dismissed.
|
||||
// Server copy is a newer version with different content, and not yet dismissed.
|
||||
showPrompt: boolean;
|
||||
reload: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects when the open dashboard changed on the server (another tab/user) without
|
||||
* touching the render cache: a separate query (own key) refetches on window focus
|
||||
* and its updatedAt is compared to the loaded copy's. Guarded by in-flight
|
||||
* mutations so an optimistic save doesn't false-positive.
|
||||
* Prompts when another tab/user changed the open dashboard. The page's own query is frozen
|
||||
* (`staleTime: Infinity`), so we re-probe the server on tab/app return (not on first load) and
|
||||
* prompt only when it's strictly newer AND its content (spec, tags, or lock state) differs from
|
||||
* what we're viewing — so our own edits, which advance the render cache, don't trip it.
|
||||
* In-flight mutations are skipped.
|
||||
*/
|
||||
export function useDashboardStaleCheck(
|
||||
dashboardId: string,
|
||||
loadedUpdatedAt: string | undefined,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
refetch: () => void,
|
||||
): UseDashboardStaleCheck {
|
||||
const {
|
||||
id: dashboardId,
|
||||
updatedAt: loadedUpdatedAt,
|
||||
spec: loadedSpec,
|
||||
tags: loadedTags,
|
||||
locked: loadedLocked,
|
||||
} = dashboard;
|
||||
const isMutating = useIsMutating() > 0;
|
||||
const queryClient = useQueryClient();
|
||||
const [server, setServer] = useState<DashboardtypesGettableDashboardV2DTO>();
|
||||
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
|
||||
|
||||
const { data } = useQuery(
|
||||
['dashboard-freshness', dashboardId],
|
||||
({ signal }) => getDashboardV2({ id: dashboardId }, signal),
|
||||
{
|
||||
enabled: !!dashboardId,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
// Seed from the already-loaded dashboard so mount makes no extra GET; the
|
||||
// query only hits the network on a later window focus.
|
||||
initialData: () =>
|
||||
queryClient.getQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
),
|
||||
},
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
const probe = async (): Promise<void> => {
|
||||
// `visibilitychange` also fires on the hidden transition — only probe on return.
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getDashboardV2({ id: dashboardId });
|
||||
setServer(response.data);
|
||||
} catch {
|
||||
// A failed freshness probe must never surface to the viewer.
|
||||
}
|
||||
};
|
||||
// `visibilitychange` catches tab switches; `focus` catches app/window switches.
|
||||
document.addEventListener('visibilitychange', probe);
|
||||
window.addEventListener('focus', probe);
|
||||
return (): void => {
|
||||
document.removeEventListener('visibilitychange', probe);
|
||||
window.removeEventListener('focus', probe);
|
||||
};
|
||||
}, [dashboardId]);
|
||||
|
||||
const serverUpdatedAt = server?.updatedAt;
|
||||
|
||||
// Content the viewer cares about: panels/layout/variables (spec), tags, and lock state.
|
||||
// Deep, order-independent so an optimistic patch's key reordering isn't read as a change.
|
||||
const contentDiffers = useMemo(
|
||||
() =>
|
||||
!!server &&
|
||||
(!isEqual(server.spec, loadedSpec) ||
|
||||
!isEqual(server.tags, loadedTags) ||
|
||||
server.locked !== loadedLocked),
|
||||
[server, loadedSpec, loadedTags, loadedLocked],
|
||||
);
|
||||
|
||||
const serverUpdatedAt = data?.data?.updatedAt;
|
||||
const changed =
|
||||
!isMutating &&
|
||||
!!serverUpdatedAt &&
|
||||
!!loadedUpdatedAt &&
|
||||
serverUpdatedAt !== loadedUpdatedAt;
|
||||
new Date(serverUpdatedAt).getTime() > new Date(loadedUpdatedAt).getTime() &&
|
||||
contentDiffers;
|
||||
|
||||
const dismiss = useCallback(
|
||||
(): void => setDismissedAt(serverUpdatedAt ?? null),
|
||||
[serverUpdatedAt],
|
||||
);
|
||||
// Dismiss + refetch: close on click and pull the latest; a newer, different version re-prompts.
|
||||
const reload = useCallback((): void => {
|
||||
setDismissedAt(serverUpdatedAt ?? null);
|
||||
refetch();
|
||||
setDismissedAt(null);
|
||||
}, [refetch]);
|
||||
}, [refetch, serverUpdatedAt]);
|
||||
|
||||
return {
|
||||
showPrompt: changed && serverUpdatedAt !== dismissedAt,
|
||||
|
||||
@@ -55,11 +55,7 @@ function DashboardContainer({
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(
|
||||
dashboard.id,
|
||||
dashboard.updatedAt,
|
||||
refetch,
|
||||
);
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
export interface CollapseSlice {
|
||||
collapsed: Record<string, Record<string, boolean>>;
|
||||
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
|
||||
// Whether the variables bar shows its full (expanded) list, per dashboard.
|
||||
// Absent → collapsed (the default).
|
||||
variablesExpanded: Record<string, boolean>;
|
||||
setVariablesExpanded: (dashboardId: string, expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const createCollapseSlice: StateCreator<
|
||||
@@ -32,4 +36,17 @@ export const createCollapseSlice: StateCreator<
|
||||
},
|
||||
});
|
||||
},
|
||||
variablesExpanded: {},
|
||||
setVariablesExpanded: (dashboardId, expanded): void => {
|
||||
const { variablesExpanded } = get();
|
||||
set({
|
||||
variablesExpanded: { ...variablesExpanded, [dashboardId]: expanded },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: is the variables bar expanded for this dashboard? Default collapsed. */
|
||||
export const selectVariablesExpanded =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
Boolean(state.variablesExpanded[dashboardId]);
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variablesExpanded: state.variablesExpanded,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
SquareArrowOutUpRight,
|
||||
Tag,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import DeleteActionItem from './DeleteActionItem';
|
||||
import EditTagsModal from './EditTagsModal';
|
||||
import RenameDashboardModal from './RenameDashboardModal';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
@@ -39,6 +41,8 @@ interface Props {
|
||||
dashboardName: string;
|
||||
createdBy: string;
|
||||
isLocked: boolean;
|
||||
// Current tags as `key:value` strings, for the inline tag editor.
|
||||
tags: string[];
|
||||
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
|
||||
canEdit: boolean;
|
||||
onView: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
@@ -50,6 +54,7 @@ function ActionsPopover({
|
||||
dashboardName,
|
||||
createdBy,
|
||||
isLocked,
|
||||
tags,
|
||||
canEdit,
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
@@ -57,6 +62,7 @@ function ActionsPopover({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
@@ -165,6 +171,35 @@ function ActionsPopover({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked
|
||||
? 'This dashboard is locked, so its tags cannot be edited.'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Tag size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsEditTagsOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-edit-tags"
|
||||
>
|
||||
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -231,6 +266,12 @@ function ActionsPopover({
|
||||
currentName={dashboardName}
|
||||
onClose={(): void => setIsRenameOpen(false)}
|
||||
/>
|
||||
<EditTagsModal
|
||||
open={isEditTagsOpen}
|
||||
dashboardId={dashboardId}
|
||||
currentTags={tags}
|
||||
onClose={(): void => setIsEditTagsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
// Current tags as `key:value` strings.
|
||||
currentTags: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a dashboard's tags from the list via an `add /tags` patch, then refreshes
|
||||
* the list. `add` is used (not `replace`) so it works whether the tags field is
|
||||
* already present or absent on the stored dashboard.
|
||||
*/
|
||||
function EditTagsModal({
|
||||
open,
|
||||
dashboardId,
|
||||
currentTags,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const [tags, setTags] = useState<string[]>(currentTags);
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reset to the dashboard's tags when the modal opens. Keyed by content
|
||||
// (`tagsKey`) rather than array identity, since `currentTags` is a fresh array
|
||||
// on each parent render; reconstructing from the key keeps the effect deps
|
||||
// stable and the reset off the per-render path.
|
||||
const tagsKey = currentTags.join('\n');
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTags(tagsKey ? tagsKey.split('\n') : []);
|
||||
}
|
||||
}, [open, tagsKey]);
|
||||
|
||||
const { mutate: runSave, isLoading } = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/tags',
|
||||
value: keyValueStringsToTags(tags),
|
||||
},
|
||||
];
|
||||
return patchDashboardV2({ id: dashboardId }, ops);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Tags updated');
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Save on Cmd/Ctrl+Enter from anywhere in the modal (not just the tag input),
|
||||
// while it is open.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, runSave]);
|
||||
|
||||
const title = currentTags.length > 0 ? 'Edit tags' : 'Add tags';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title={title}
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.renameFooter}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="edit-tags-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => runSave()}
|
||||
testId="edit-tags-submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TagKeyValueInput
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder="key:value (press Enter)"
|
||||
testId="edit-dashboard-tags"
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditTagsModal;
|
||||
@@ -60,6 +60,12 @@ function DashboardRow({
|
||||
);
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
// Clicks inside portaled overlays (the actions menu, edit modals) bubble here
|
||||
// through React's tree even though they render outside the row in the DOM.
|
||||
// Only navigate when the click actually landed inside the row.
|
||||
if (!event.currentTarget.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
@@ -159,6 +165,7 @@ function DashboardRow({
|
||||
dashboardName={name}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
tags={tags}
|
||||
canEdit={canEdit}
|
||||
onView={onClickHandler}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,8 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
|
||||
import DashboardImagePicker from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker';
|
||||
import { Base64Icons } from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
@@ -32,6 +34,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [image, setImage] = useState<string>(Base64Icons[0]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -48,6 +51,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
image,
|
||||
tags: postableTags.length ? postableTags : null,
|
||||
spec: {
|
||||
display: {
|
||||
@@ -77,21 +81,29 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <Typography.Text className={styles.required}>*</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
<div className={styles.titleRow}>
|
||||
<DashboardImagePicker
|
||||
image={image}
|
||||
onChange={setImage}
|
||||
triggerClassName={styles.imageTrigger}
|
||||
/>
|
||||
<Input
|
||||
className={styles.titleInput}
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
// Title row: icon picker joined flush to the left of the name input.
|
||||
.titleRow {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
// The icon picker renders its dropdown inline (no portal); lift it above the
|
||||
// description/tags fields below it.
|
||||
:global([data-radix-popper-content-wrapper]) {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.imageTrigger {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
@@ -60,28 +82,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cardName {
|
||||
color: var(--l1-foreground);
|
||||
// "From a template" tab: a centered browse-and-request placeholder until the
|
||||
// templates BE lands. Fixed height matches .panel so switching tabs won't resize.
|
||||
.templatesPanel {
|
||||
height: 460px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 6px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.templatesIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 10px;
|
||||
color: var(--bg-robin-400);
|
||||
background: var(--callout-primary-background);
|
||||
border: 1px solid var(--callout-primary-border);
|
||||
}
|
||||
|
||||
.templatesDesc {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.browseLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
color: var(--bg-robin-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 12px;
|
||||
|
||||
:global(.request-entity-container) {
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
&:hover {
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
|
||||
.requestForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.requestInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importHeader {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
@@ -108,73 +166,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templatesLayout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.templatesList {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.templateItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.templateItem:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.templateItemActive {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--primary-background);
|
||||
}
|
||||
|
||||
.templateName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.templateCat {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.templatesPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.previewHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,7 +43,7 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel onClose={onClose} />,
|
||||
children: <TemplatesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
|
||||
@@ -1,67 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
const TEMPLATES_DOCS_URL =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
|
||||
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
|
||||
// so the action creates a blank dashboard.
|
||||
function TemplatesPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Templates aren't served by the BE yet, so this tab is a browse-and-request
|
||||
// placeholder: link out to the published template library, and let cloud users
|
||||
// request one we haven't built.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
const [name, setName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (creating) {
|
||||
const requestName = name.trim();
|
||||
|
||||
const handleRequest = async (): Promise<void> => {
|
||||
if (!requestName || submitting) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', {});
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: 'Sample Dashboard' },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
setSubmitting(true);
|
||||
const response = await logEvent('Dashboard Requested', {
|
||||
screen: 'Dashboard list page',
|
||||
dashboard: requestName,
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setCreating(false);
|
||||
if (response.statusCode === 200) {
|
||||
toast.success('Dashboard request submitted');
|
||||
setName('');
|
||||
} else {
|
||||
toast.error(response.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className="new-dashboard-templates-modal">
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.templatesPanel}>
|
||||
<span className={styles.templatesIcon}>
|
||||
<LayoutDashboard size={20} />
|
||||
</span>
|
||||
<Typography variant="title" size="lg" weight="semibold">
|
||||
Dashboard templates
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="muted"
|
||||
className={styles.templatesDesc}
|
||||
>
|
||||
Browse our library of ready-made dashboards, or request a new one and
|
||||
we'll build it for you.
|
||||
</Typography>
|
||||
|
||||
<a
|
||||
className={styles.browseLink}
|
||||
href={TEMPLATES_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Browse dashboard templates
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</a>
|
||||
|
||||
{isCloudUser && (
|
||||
<div className={styles.requestForm}>
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
): string[] =>
|
||||
(tags ?? []).map((tag) =>
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
// Always render both sides (so an env:env tag reads "env:env", not a lone
|
||||
// "env"); fall back to the bare key only when there's no value.
|
||||
(tags ?? []).map((tag) => (tag.value ? `${tag.key}:${tag.value}` : tag.key));
|
||||
|
||||
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
|
||||
// postable tag DTO shape. The first colon separates key from value.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -20,21 +20,19 @@ func (c Compiled) IsEmpty() bool {
|
||||
// Compile always returns a non-nil *Compiled. An empty query (or one that
|
||||
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
|
||||
// SQL != "" rather than a nil check.
|
||||
//
|
||||
// A valid `key OP value` filter compiles to the DSL predicate; a plain string
|
||||
// (bare words, spaces allowed) is a case-insensitive substring search over the
|
||||
// dashboard name, description, and tag keys/values; anything else is an error.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
|
||||
if len(query) == 0 {
|
||||
if len(strings.TrimSpace(query)) == 0 {
|
||||
return &Compiled{}, nil
|
||||
}
|
||||
|
||||
queryVisitor := newVisitor(formatter)
|
||||
sql, args, syntaxErrs := queryVisitor.compile(query)
|
||||
|
||||
if len(syntaxErrs) > 0 {
|
||||
sql, args, errs := newVisitor(formatter).compile(query)
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
|
||||
}
|
||||
if len(queryVisitor.errors) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
return &Compiled{
|
||||
|
||||
@@ -460,6 +460,63 @@ func TestCompile_ComplexExamples(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_FreeText(t *testing.T) {
|
||||
// freeTextSQL is the predicate every free-text query compiles to; only the
|
||||
// bound pattern differs.
|
||||
freeTextSQL := `
|
||||
(
|
||||
lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR lower(json_extract("dashboard"."data", '$.spec.display.description')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
|
||||
AND (lower(t.key) LIKE LOWER(?) ESCAPE '\' OR lower(t.value) LIKE LOWER(?) ESCAPE '\')
|
||||
))`
|
||||
freeTextArgs := func(pattern string) []any {
|
||||
return []any{pattern, pattern, kindArg, pattern, pattern}
|
||||
}
|
||||
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "single bare word",
|
||||
dslQueryToCompile: `payment`,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs("%payment%"),
|
||||
},
|
||||
{
|
||||
subtestName: "phrase with spaces matches the whole string",
|
||||
dslQueryToCompile: `prod payment service`,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs("%prod payment service%"),
|
||||
},
|
||||
{
|
||||
subtestName: "quoted phrase",
|
||||
dslQueryToCompile: `"prod payment service"`,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs("%prod payment service%"),
|
||||
},
|
||||
{
|
||||
subtestName: "quoting is the escape hatch for a DSL-like literal",
|
||||
dslQueryToCompile: `"team = prod"`,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs("%team = prod%"),
|
||||
},
|
||||
{
|
||||
subtestName: "LIKE wildcards in the term are escaped to match literally",
|
||||
dslQueryToCompile: `50% off`,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs(`%50\% off%`),
|
||||
},
|
||||
{
|
||||
subtestName: "surrounding whitespace is trimmed",
|
||||
dslQueryToCompile: ` payment `,
|
||||
expectedSQL: freeTextSQL,
|
||||
expectedArgs: freeTextArgs("%payment%"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_Rejections(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
@@ -492,6 +549,11 @@ func TestCompile_Rejections(t *testing.T) {
|
||||
dslQueryToCompile: `name = `,
|
||||
expectedErrShouldContain: "syntax",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects a bare term mixed into a real comparison",
|
||||
dslQueryToCompile: `payment AND name = 'foo'`,
|
||||
expectedErrShouldContain: "unsupported expression",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ type visitor struct {
|
||||
selectBuilder *sqlbuilder.SelectBuilder
|
||||
formatter sqlstore.SQLFormatter
|
||||
errors []string
|
||||
// bareTerms are primaries that were a lone key/value/full-text token, not a
|
||||
// `key OP value` comparison. compile uses them to tell a free-text search (all
|
||||
// bare terms) from a malformed filter (a bare term beside a comparison).
|
||||
bareTerms []string
|
||||
}
|
||||
|
||||
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
|
||||
@@ -32,13 +36,37 @@ func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
|
||||
}
|
||||
}
|
||||
|
||||
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
|
||||
// compile builds `?`-placeholder WHERE SQL + args for bun: a `key OP value`
|
||||
// filter becomes the DSL predicate, an all-bare-terms query a free-text search,
|
||||
// anything malformed an error.
|
||||
func (v *visitor) compile(query string) (string, []any, []string) {
|
||||
tree, _, collector := filterquery.Parse(query)
|
||||
if len(collector.Errors) > 0 {
|
||||
return "", nil, collector.Errors
|
||||
}
|
||||
condition, _ := v.visit(tree).(string)
|
||||
|
||||
// The query parsed into nothing but bare terms — treat it as a free-text search.
|
||||
if condition == "" && len(v.errors) == 0 && len(v.bareTerms) > 0 {
|
||||
value := strings.TrimSpace(query)
|
||||
// A lone term spanning the whole query may be a quoted string — unquote it
|
||||
// so a user can search a DSL-like literal, e.g. `"team = prod"`. A
|
||||
// multi-word phrase is searched verbatim.
|
||||
if len(v.bareTerms) == 1 && v.bareTerms[0] == value {
|
||||
value = trimQuotes(value)
|
||||
}
|
||||
sql, arguments := v.compileFreeText(value)
|
||||
return sql, arguments, nil
|
||||
}
|
||||
|
||||
// A bare term alongside a real comparison is not valid DSL.
|
||||
for _, bareTerm := range v.bareTerms {
|
||||
v.addError("unsupported expression %q — every term must be of the form `key OP value`", bareTerm)
|
||||
}
|
||||
if len(v.errors) > 0 {
|
||||
return "", nil, v.errors
|
||||
}
|
||||
|
||||
if condition == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
@@ -119,9 +147,9 @@ func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.Comparison() != nil {
|
||||
return v.visit(ctx.Comparison())
|
||||
}
|
||||
// Bare keys, values, full text, and function calls are not part of the
|
||||
// dashboard list DSL.
|
||||
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
|
||||
// A lone key/value/full-text token, not a comparison — recorded as a bare
|
||||
// term for compile to interpret.
|
||||
v.bareTerms = append(v.bareTerms, ctx.GetText())
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -401,6 +429,50 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t
|
||||
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
|
||||
}
|
||||
|
||||
// ─── free-text search ────────────────────────────────────────────────────────
|
||||
|
||||
// compileFreeText matches value as a case-insensitive substring of the dashboard
|
||||
// name, description, or any tag key/value.
|
||||
func (v *visitor) compileFreeText(value string) (string, []any) {
|
||||
nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
|
||||
descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
|
||||
namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value)
|
||||
descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value)
|
||||
|
||||
subqueryBuilder := sqlbuilder.NewSelectBuilder()
|
||||
keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value)
|
||||
valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value)
|
||||
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
|
||||
tagPredicate := v.selectBuilder.Exists(subqueryBuilder)
|
||||
|
||||
condition := v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate)
|
||||
return v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
|
||||
}
|
||||
|
||||
// buildFreeTextContains emits a case-insensitive contains as LOWER(col) LIKE
|
||||
// LOWER(?) — identical on SQLite and Postgres. The value's % and _ are escaped to
|
||||
// match literally, and ESCAPE pins backslash as the escape char (SQLite has no
|
||||
// default; a harmless restatement of the Postgres default).
|
||||
func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string {
|
||||
lowerColumn := string(v.formatter.LowerExpression(columnExpression))
|
||||
pattern := "%" + v.formatter.EscapeLikePattern(value) + "%"
|
||||
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern))
|
||||
}
|
||||
|
||||
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
|
||||
const dashboardTagKind = `"dashboard"`
|
||||
|
||||
return subqueryBuilder.
|
||||
Select("1").
|
||||
From("tag_relation tr").
|
||||
Join("tag t", "t.id = tr.tag_id").
|
||||
Where(
|
||||
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
|
||||
"tr.resource_id = dashboard.id",
|
||||
subqueryBuilder.Or(keyPredicate, valuePredicate),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── value extraction helpers ───────────────────────────────────────────────
|
||||
|
||||
func (v *visitor) addError(format string, arguments ...any) {
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
808
pkg/modules/inframonitoring/implinframonitoring/containers.go
Normal file
808
pkg/modules/inframonitoring/implinframonitoring/containers.go
Normal 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, ¬Ready)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const containerNameAttrKey = inframonitoringtypes.ContainerNameAttrKey
|
||||
|
||||
const (
|
||||
containerStatusStateAttrKey = "k8s.container.status.state"
|
||||
containerStatusReasonAttrKey = "k8s.container.status.reason"
|
||||
)
|
||||
|
||||
const (
|
||||
containerStatusStateMetricName = "k8s.container.status.state"
|
||||
containerStatusReasonMetricName = "k8s.container.status.reason"
|
||||
containerReadyMetricName = "k8s.container.ready"
|
||||
containerRestartsMetricName = "k8s.container.restarts"
|
||||
)
|
||||
|
||||
// containerStatusMetricNamesList are the metrics required to derive the
|
||||
// kubectl-style container display status. Gated by
|
||||
// getPerGroupContainerStatusCountsWithReqMetricChecks — if either is missing,
|
||||
// status is skipped and a warning is returned.
|
||||
var containerStatusMetricNamesList = []string{
|
||||
containerStatusStateMetricName,
|
||||
containerStatusReasonMetricName,
|
||||
}
|
||||
|
||||
var containerNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: containerNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
// containerRowGroupBy is the default row-identity groupBy for the containers
|
||||
// list: (k8s.pod.uid, k8s.container.name). Stable across container restarts.
|
||||
// podUIDGroupByKey is shared with the pods module (pods_constants.go).
|
||||
var containerRowGroupBy = []qbtypes.GroupByKey{podUIDGroupByKey, containerNameGroupByKey}
|
||||
|
||||
// containersTableMetricNamesList are the metrics that carry the container
|
||||
// attributes; used for metadata resolution and the retention check.
|
||||
var containersTableMetricNamesList = []string{
|
||||
"container.cpu.usage",
|
||||
"k8s.container.cpu_request_utilization",
|
||||
"k8s.container.cpu_limit_utilization",
|
||||
"container.memory.working_set",
|
||||
"k8s.container.memory_request_utilization",
|
||||
"k8s.container.memory_limit_utilization",
|
||||
"k8s.container.restarts",
|
||||
"k8s.container.ready",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.container.status.state",
|
||||
}
|
||||
|
||||
var containerAttrKeysForMetadata = []string{
|
||||
"k8s.pod.uid",
|
||||
"k8s.container.name",
|
||||
"k8s.pod.name",
|
||||
"container.image.name",
|
||||
"container.image.tag",
|
||||
"k8s.namespace.name",
|
||||
"k8s.node.name",
|
||||
"k8s.deployment.name",
|
||||
"k8s.statefulset.name",
|
||||
"k8s.daemonset.name",
|
||||
"k8s.job.name",
|
||||
"k8s.cronjob.name",
|
||||
"k8s.cluster.name",
|
||||
}
|
||||
|
||||
var orderByToContainersQueryNames = map[string][]string{
|
||||
inframonitoringtypes.ContainersOrderByCPU: {"A"},
|
||||
inframonitoringtypes.ContainersOrderByCPURequest: {"B"},
|
||||
inframonitoringtypes.ContainersOrderByCPULimit: {"C"},
|
||||
inframonitoringtypes.ContainersOrderByMemory: {"D"},
|
||||
inframonitoringtypes.ContainersOrderByMemoryRequest: {"E"},
|
||||
inframonitoringtypes.ContainersOrderByMemoryLimit: {"F"},
|
||||
}
|
||||
|
||||
// newContainersTableListQuery builds the composite QB v5 request for the
|
||||
// containers list (kubeletstats usage/utilization). Status, restarts and ready
|
||||
// come from k8sclusterreceiver via dedicated queries (works for both list and
|
||||
// grouped_list modes), so not included here.
|
||||
func (m *module) newContainersTableListQuery() *qbtypes.QueryRangeRequest {
|
||||
queries := []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage (cores)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "container.cpu.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query B: CPU request utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.container.cpu_request_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: CPU limit utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.container.cpu_limit_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query D: Memory working set
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "container.memory.working_set",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query E: Memory request utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "E",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.container.memory_request_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query F: Memory limit utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.container.memory_limit_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: containerRowGroupBy,
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: queries,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,30 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) ListContainers(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostableContainers
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListContainers(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) ListNodes(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -132,3 +132,61 @@ func (s checkSpec) getAllAttrs() []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containerStatusCounts holds per-group container counts bucketed by latest
|
||||
// kubectl-style display status in window. Mirrors inframonitoringtypes.ContainerCountsByStatus.
|
||||
type containerStatusCounts struct {
|
||||
// State fallback.
|
||||
Running int
|
||||
Waiting int
|
||||
Terminated int
|
||||
|
||||
// Container-level reasons.
|
||||
CrashLoopBackOff int
|
||||
ImagePullBackOff int
|
||||
ErrImagePull int
|
||||
CreateContainerConfigError int
|
||||
ContainerCreating int
|
||||
OOMKilled int
|
||||
Completed int
|
||||
Error int
|
||||
ContainerCannotRun int
|
||||
|
||||
Unknown int
|
||||
}
|
||||
|
||||
// containerStatusCountsToResponse copies the internal per-group status counts
|
||||
// into the public response struct.
|
||||
func containerStatusCountsToResponse(c containerStatusCounts) inframonitoringtypes.ContainerCountsByStatus {
|
||||
return inframonitoringtypes.ContainerCountsByStatus{
|
||||
Running: c.Running,
|
||||
Waiting: c.Waiting,
|
||||
Terminated: c.Terminated,
|
||||
CrashLoopBackOff: c.CrashLoopBackOff,
|
||||
ImagePullBackOff: c.ImagePullBackOff,
|
||||
ErrImagePull: c.ErrImagePull,
|
||||
CreateContainerConfigError: c.CreateContainerConfigError,
|
||||
ContainerCreating: c.ContainerCreating,
|
||||
OOMKilled: c.OOMKilled,
|
||||
Completed: c.Completed,
|
||||
Error: c.Error,
|
||||
ContainerCannotRun: c.ContainerCannotRun,
|
||||
Unknown: c.Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
// containerReadyCounts holds per-group container counts bucketed by latest
|
||||
// readiness in window. Mirrors inframonitoringtypes.ContainerCountsByReady.
|
||||
type containerReadyCounts struct {
|
||||
Ready int
|
||||
NotReady int
|
||||
}
|
||||
|
||||
// containerReadyCountsToResponse copies the internal per-group ready counts
|
||||
// into the public response struct.
|
||||
func containerReadyCountsToResponse(c containerReadyCounts) inframonitoringtypes.ContainerCountsByReady {
|
||||
return inframonitoringtypes.ContainerCountsByReady{
|
||||
Ready: c.Ready,
|
||||
NotReady: c.NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,110 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (*inframonitoringtypes.Containers, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListContainers")
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Containers{}
|
||||
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: inframonitoringtypes.ContainersOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = containerRowGroupBy
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
minFirstReportedUnixMilli, err := m.getEarliestMetricTime(ctx, containersTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.ContainerRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getContainersTableMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopContainerGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.ContainerRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newContainersTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
statusCounts map[string]containerStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
restartCounts map[string]int64
|
||||
readyCounts map[string]containerReadyCounts
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isContainerNameAndPodUIDInGroupBy := isKeyInGroupByAttrs(req.GroupBy, containerNameAttrKey) && isKeyInGroupByAttrs(req.GroupBy, podUIDAttrKey)
|
||||
resp.Records = buildContainerRecords(isContainerNameAndPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, statusCounts, restartCounts, readyCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, statusWarning)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
|
||||
ctx = m.withInfraMonitoringContext(ctx, "ListNodes")
|
||||
|
||||
|
||||
@@ -8,15 +8,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
containerNameAttrKey = "k8s.container.name"
|
||||
containerStatusReasonAttrKey = "k8s.container.status.reason"
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
podStatusReasonMetricName = "k8s.pod.status_reason"
|
||||
containerStatusReasonMetricName = "k8s.container.status.reason"
|
||||
containerRestartsMetricName = "k8s.container.restarts"
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
podStatusReasonMetricName = "k8s.pod.status_reason"
|
||||
)
|
||||
|
||||
// podStatusMetricNamesList are the metrics required to derive the kubectl-style
|
||||
@@ -45,8 +41,8 @@ var podsTableMetricNamesList = []string{
|
||||
"k8s.pod.memory_limit_utilization",
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.container.restarts",
|
||||
// "k8s.container.status.reason",
|
||||
// "k8s.container.restarts",
|
||||
}
|
||||
|
||||
var podAttrKeysForMetadata = []string{
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
ListPods(http.ResponseWriter, *http.Request)
|
||||
ListContainers(http.ResponseWriter, *http.Request)
|
||||
ListNodes(http.ResponseWriter, *http.Request)
|
||||
ListNamespaces(http.ResponseWriter, *http.Request)
|
||||
ListClusters(http.ResponseWriter, *http.Request)
|
||||
@@ -27,6 +28,7 @@ type Module interface {
|
||||
statsreporter.StatsCollector
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
|
||||
ListContainers(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (*inframonitoringtypes.Containers, error)
|
||||
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
|
||||
ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error)
|
||||
ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error)
|
||||
|
||||
@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case telemetrytypes.GenAIRequestModel:
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -42,7 +42,6 @@ type querier struct {
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
@@ -62,7 +61,6 @@ func New(
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
promEngine prometheus.Prometheus,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -84,7 +82,6 @@ func New(
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
aiTraceStmtBuilder: aiTraceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
@@ -238,18 +235,10 @@ func (q *querier) buildQueries(
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
stmtBuilder := q.traceStmtBuilder
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
event.Source = telemetrytypes.SourceAI.StringValue()
|
||||
stmtBuilder = q.aiTraceStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not logs")
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
stmtBuilder := q.logStmtBuilder
|
||||
@@ -260,9 +249,6 @@ func (q *querier) buildQueries(
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
if spec.Source == telemetrytypes.SourceAI {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "source \"ai\" is only supported for the traces signal, not metrics")
|
||||
}
|
||||
// Spec was already patched by resolveMetricMetadata. Queries
|
||||
// whose every aggregation was missing live in
|
||||
// missingMetricQuerySet and produce empty preseeded results
|
||||
@@ -872,11 +858,7 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.traceStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAI {
|
||||
shiftStmtBuilder = q.aiTraceStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
|
||||
@@ -49,7 +49,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -122,7 +121,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
&mockMetricStmtBuilder{}, // metricStmtBuilder
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryai"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
||||
@@ -93,30 +92,6 @@ func newProvider(
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// AI trace statement builder (source=ai). The gen_ai gate/column keys are
|
||||
// surfaced by the metadata store itself (enrichWithGenAIKeys), so queries work
|
||||
// before any gen_ai metadata is ingested — no per-builder decoration needed.
|
||||
aiBaseCondition := telemetryai.NewGenAIBaseConditionProvider()
|
||||
aiDelegateTraceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
|
||||
settings,
|
||||
telemetryMetadataStore,
|
||||
traceFieldMapper,
|
||||
traceConditionBuilder,
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
flagger,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
aiTraceStmtBuilder := telemetryai.NewAITraceStatementBuilder(
|
||||
settings,
|
||||
telemetryMetadataStore,
|
||||
traceFieldMapper,
|
||||
traceConditionBuilder,
|
||||
aiBaseCondition,
|
||||
aiDelegateTraceStmtBuilder,
|
||||
)
|
||||
|
||||
// Create trace operator statement builder
|
||||
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
|
||||
settings,
|
||||
@@ -210,7 +185,6 @@ func newProvider(
|
||||
telemetryMetadataStore,
|
||||
prometheus,
|
||||
traceStmtBuilder,
|
||||
aiTraceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
metricStmtBuilder,
|
||||
|
||||
@@ -48,7 +48,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
metricStmtBuilder,
|
||||
@@ -104,7 +103,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
logStmtBuilder, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -154,7 +152,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
traceStmtBuilder, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// SplitFilterForAggregates partitions a single filter expression into a span-level
|
||||
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
|
||||
// aggregates), splitting on the top-level AND.
|
||||
//
|
||||
// A key is trace-level when, with no explicit field context, its name is in
|
||||
// aggregateNames — written bare (`completion_tokens`) or with the user-facing `trace.`
|
||||
// prefix (`trace.completion_tokens`). Any explicit context (`span.`, `resource.`, …) is
|
||||
// span-level. Trace-level and span-level keys may be AND-combined (they run at different
|
||||
// query stages) but not OR-combined; an OR that mixes the two is an error.
|
||||
//
|
||||
// Syntax errors are ignored here — each part is re-parsed downstream (PrepareWhereClause
|
||||
// for the span part, the HAVING rewriter for the trace part), which surface them.
|
||||
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
s := filterSplitter{query: query, aggregateNames: aggregateNames}
|
||||
s.visit(parseFilterQuery(query))
|
||||
|
||||
if s.mixed {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level and span-level filters cannot be combined within an OR/NOT group; separate them with a top-level AND")
|
||||
}
|
||||
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
|
||||
}
|
||||
|
||||
func parseFilterQuery(query string) antlr.Tree {
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
|
||||
lexer.RemoveErrorListeners()
|
||||
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
|
||||
parser.RemoveErrorListeners()
|
||||
return parser.Query()
|
||||
}
|
||||
|
||||
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
|
||||
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
|
||||
// to the span or having bucket by the class of the keys it references.
|
||||
type filterSplitter struct {
|
||||
query string
|
||||
aggregateNames map[string]struct{}
|
||||
span []string
|
||||
having []string
|
||||
mixed bool
|
||||
}
|
||||
|
||||
func (s *filterSplitter) visit(node antlr.Tree) {
|
||||
switch n := node.(type) {
|
||||
case *grammar.QueryContext:
|
||||
if n.Expression() != nil {
|
||||
s.visit(n.Expression())
|
||||
}
|
||||
case *grammar.ExpressionContext:
|
||||
if n.OrExpression() != nil {
|
||||
s.visit(n.OrExpression())
|
||||
}
|
||||
case *grammar.OrExpressionContext:
|
||||
// a single branch is just an AND chain; multiple branches are a real OR, kept
|
||||
// whole so a class-mixing OR can be rejected.
|
||||
if ands := n.AllAndExpression(); len(ands) == 1 {
|
||||
s.visit(ands[0])
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
case *grammar.AndExpressionContext:
|
||||
for _, u := range n.AllUnaryExpression() {
|
||||
s.visit(u)
|
||||
}
|
||||
case *grammar.UnaryExpressionContext:
|
||||
if n.NOT() != nil {
|
||||
s.route(n)
|
||||
} else if n.Primary() != nil {
|
||||
s.visit(n.Primary())
|
||||
}
|
||||
case *grammar.PrimaryContext:
|
||||
if n.OrExpression() != nil { // parenthesized sub-expression
|
||||
s.visit(n.OrExpression())
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route classifies an atom and appends its original source text to the right bucket.
|
||||
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
|
||||
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
|
||||
if isTrace && isSpan {
|
||||
s.mixed = true
|
||||
return
|
||||
}
|
||||
text := atomSourceText(s.query, atom)
|
||||
// A multi-branch OR group's source slice excludes its enclosing parens (they belong
|
||||
// to the parent Primary). Re-wrap it so rejoining a bucket with " AND " cannot invert
|
||||
// OR/AND precedence, e.g. `a AND (b OR c)` must not flatten to `a AND b OR c`.
|
||||
if or, ok := atom.(*grammar.OrExpressionContext); ok && len(or.AllAndExpression()) > 1 {
|
||||
text = "(" + text + ")"
|
||||
}
|
||||
if isTrace {
|
||||
s.having = append(s.having, text)
|
||||
} else {
|
||||
s.span = append(s.span, text)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyKeys reports whether a subtree references trace-level and/or span-level keys.
|
||||
// A key is trace-level only when it has no explicit field context and its name — after
|
||||
// the optional user-facing `trace.` prefix is stripped — is a known aggregate. Any
|
||||
// explicit context (`span.`, `resource.`, …) is span-level.
|
||||
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
|
||||
kc, ok := node.(*grammar.KeyContext)
|
||||
if ok {
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
|
||||
if key.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
// `trace.` is the user-facing prefix for trace-level aggregates. It is not a
|
||||
// registered field context, so it stays on the name; strip it before matching.
|
||||
name := strings.TrimPrefix(key.Name, telemetrytypes.FieldContextTrace.StringValue()+".")
|
||||
_, isTrace = aggregateNames[name]
|
||||
isSpan = !isTrace
|
||||
} else {
|
||||
isSpan = true
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < node.GetChildCount(); i++ {
|
||||
t, s := classifyKeys(node.GetChild(i), aggregateNames)
|
||||
isTrace = isTrace || t
|
||||
isSpan = isSpan || s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// atomSourceText returns the original source substring for an atom, preserving
|
||||
// whitespace. The token stream drops skipped whitespace, which would glue word
|
||||
// operators (OR/AND/NOT) to their operands, so slice the input by char offsets.
|
||||
func atomSourceText(query string, atom antlr.ParserRuleContext) string {
|
||||
start, stop := atom.GetStart(), atom.GetStop()
|
||||
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
|
||||
return atom.GetText()
|
||||
}
|
||||
return query[start.GetStart() : stop.GetStop()+1]
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSplitFilterForAggregates(t *testing.T) {
|
||||
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
query string
|
||||
span string // expected span-level (WHERE) part; "" => empty
|
||||
having string // expected trace-level (HAVING) part; "" => empty
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
// --- empty input ---------------------------------------------------------
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
query: " ",
|
||||
},
|
||||
|
||||
// --- single class --------------------------------------------------------
|
||||
{
|
||||
name: "span only",
|
||||
query: "service.name = 'x'",
|
||||
span: "service.name = 'x'",
|
||||
},
|
||||
{
|
||||
name: "agg only bare",
|
||||
query: "completion_tokens > 1000",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// the user-facing `trace.` prefix marks a trace-level aggregate.
|
||||
name: "agg only trace prefix",
|
||||
query: "trace.completion_tokens > 1000",
|
||||
having: "trace.completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// `tracefield.` is not supported: it has an explicit context, so it is span-level.
|
||||
name: "tracefield prefix is span-level",
|
||||
query: "tracefield.completion_tokens > 1000",
|
||||
span: "tracefield.completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- top-level AND splits across the two buckets -------------------------
|
||||
{
|
||||
name: "span AND agg",
|
||||
query: "service.name = 'x' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// order within a bucket is preserved; the two span atoms join with AND.
|
||||
name: "span AND span AND agg",
|
||||
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x' AND kind_string = 'Internal'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// a parenthesized top-level AND still splits across the two buckets.
|
||||
name: "parenthesized span AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000)",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
|
||||
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
|
||||
{
|
||||
name: "agg OR agg",
|
||||
query: "completion_tokens > 1000 OR span_count > 3",
|
||||
having: "(completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
name: "span OR span",
|
||||
query: "service.name = 'x' OR kind_string = 'Internal'",
|
||||
span: "(service.name = 'x' OR kind_string = 'Internal')",
|
||||
},
|
||||
{
|
||||
name: "span AND (span OR span)",
|
||||
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
},
|
||||
{
|
||||
name: "agg AND (agg OR agg)",
|
||||
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
// the OR group routes to span, the trailing aggregate to having.
|
||||
name: "span AND (span OR span) AND agg",
|
||||
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
|
||||
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- a nested AND group flattens across the buckets (no spurious parens) --
|
||||
{
|
||||
name: "(span AND agg) AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000 AND prompt_tokens > 5",
|
||||
},
|
||||
|
||||
// --- NOT wrapping a single-class group is routed whole to that class ------
|
||||
{
|
||||
name: "not agg",
|
||||
query: "NOT (completion_tokens > 1000)",
|
||||
having: "NOT (completion_tokens > 1000)",
|
||||
},
|
||||
{
|
||||
name: "not span",
|
||||
query: "NOT (service.name = 'x')",
|
||||
span: "NOT (service.name = 'x')",
|
||||
},
|
||||
|
||||
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
|
||||
{
|
||||
name: "agg OR span rejected",
|
||||
query: "completion_tokens > 1000 OR service.name = 'x'",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not mixed rejected",
|
||||
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "span AND (agg OR span) rejected",
|
||||
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
span, having, err := SplitFilterForAggregates(c.query, agg)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.span, span, "span part")
|
||||
require.Equal(t, c.having, having, "having part")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,6 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
|
||||
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
|
||||
// the result is a bare SQL boolean expression with no bound args. Used by callers
|
||||
// that project their own aggregate columns (e.g. the AI trace list) rather than the
|
||||
// query's Aggregations.
|
||||
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r.columnMap = columnMap
|
||||
return r.rewriteAndValidate(expression)
|
||||
}
|
||||
|
||||
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
|
||||
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// BaseConditionProvider defines which spans (and traces) are in scope, decoupled
|
||||
// from the query. Swap it to redefine "an AI trace" without touching the builder.
|
||||
//
|
||||
// It only *declares* the gate (a grammar expression + its field keys); the builder
|
||||
// resolves those keys through the field mapper, so all attribute access is
|
||||
// materialization/evolution aware — no hardcoded map lookups.
|
||||
type BaseConditionProvider interface {
|
||||
// FilterExpression is the grammar-level (EXISTS) gate, resolved via the visitor.
|
||||
FilterExpression() string
|
||||
// FieldKeys are the gate's keys: registered in metadata and used to build the
|
||||
// per-span mask (OR of resolved EXISTS conditions).
|
||||
FieldKeys() []*telemetrytypes.TelemetryFieldKey
|
||||
}
|
||||
|
||||
// genAIBaseConditionProvider: an AI trace has >=1 gen_ai LLM, tool, or agent span.
|
||||
type genAIBaseConditionProvider struct {
|
||||
keys []string
|
||||
}
|
||||
|
||||
var _ BaseConditionProvider = (*genAIBaseConditionProvider)(nil)
|
||||
|
||||
func NewGenAIBaseConditionProvider() BaseConditionProvider {
|
||||
return &genAIBaseConditionProvider{
|
||||
keys: []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *genAIBaseConditionProvider) FilterExpression() string {
|
||||
parts := make([]string, 0, len(p.keys))
|
||||
for _, k := range p.keys {
|
||||
parts = append(parts, k+" EXISTS")
|
||||
}
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func (p *genAIBaseConditionProvider) FieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(p.keys))
|
||||
for _, k := range p.keys {
|
||||
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: k,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type TraceColumn struct {
|
||||
Alias string
|
||||
// Orderable marks a column computable in the mask-pruned `matched` pass (a
|
||||
// gen_ai-scoped aggregate): the only columns usable for ORDER BY and the
|
||||
// aggregate filter. All-span columns (span_count, duration_nano, …) are false —
|
||||
// they are output-only, since the matched pass sees only gen_ai spans.
|
||||
Orderable bool
|
||||
Intrinsic string // fixed SQL over intrinsic columns; used verbatim (nil Attr)
|
||||
Attr *AttrAgg // field-mapper-resolved aggregate (nil Intrinsic)
|
||||
}
|
||||
|
||||
// AttrAgg describes an aggregate the builder assembles after resolving fields:
|
||||
// - Func "count" + ExistsKey -> countIf(<ExistsKey EXISTS>) (e.g. llm_call_count)
|
||||
// - Func + ValueKey -> Func(<resolved value>) (e.g. sum tokens)
|
||||
// - Func + ValueExpr + Scoped -> FuncIf(<value>, <gate mask>) (e.g. last_activity_time)
|
||||
type AttrAgg struct {
|
||||
Func string // sum | max | min | count
|
||||
ValueKey *telemetrytypes.TelemetryFieldKey // attribute value to aggregate (resolved as Float64)
|
||||
ValueExpr string // fixed value expr when ValueKey is nil (e.g. "timestamp")
|
||||
Scoped bool // wrap as <Func>If(value, <gate mask>)
|
||||
ExistsKey *telemetrytypes.TelemetryFieldKey // count spans where this key exists (countIf)
|
||||
}
|
||||
|
||||
// ProjectionProvider decides which per-trace columns the list computes and which
|
||||
// are sortable, decoupled from selection and topology.
|
||||
type ProjectionProvider interface {
|
||||
Columns() []TraceColumn
|
||||
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
|
||||
DefaultOrderAlias() string
|
||||
// AggregateAliases are the computed per-trace (trace-level) column names, used to
|
||||
// classify which filter keys are trace-level vs span-level. Only the Orderable
|
||||
// subset is actually usable in ORDER BY / the aggregate filter; the rest are
|
||||
// output-only. Excludes aliases that are also real span/resource keys (service.name).
|
||||
AggregateAliases() []string
|
||||
}
|
||||
|
||||
// CommonTraceColumns are domain-neutral intrinsic columns any trace list can reuse.
|
||||
// All are over-all-spans intrinsics, so none is Orderable (not computable in the
|
||||
// mask-pruned matched pass) — they are output-only, computed in the enrichment scan.
|
||||
func CommonTraceColumns() []TraceColumn {
|
||||
return []TraceColumn{
|
||||
{Alias: "start_time", Intrinsic: "min(timestamp)", Orderable: false},
|
||||
{Alias: "end_time", Intrinsic: "max(timestamp)", Orderable: false},
|
||||
{Alias: "duration_nano", Intrinsic: "(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp)))", Orderable: false},
|
||||
{Alias: "span_count", Intrinsic: "count()", Orderable: false},
|
||||
|
||||
{Alias: "root_span_name", Intrinsic: "anyIf(name, parent_span_id = '')", Orderable: false},
|
||||
{Alias: "service.name", Intrinsic: "any(resource_string_service$$name)", Orderable: false},
|
||||
}
|
||||
}
|
||||
|
||||
// genAIProjectionProvider adds AI/LLM per-trace metrics to the common columns.
|
||||
type genAIProjectionProvider struct{}
|
||||
|
||||
var _ ProjectionProvider = (*genAIProjectionProvider)(nil)
|
||||
|
||||
func NewGenAIProjectionProvider() ProjectionProvider {
|
||||
return &genAIProjectionProvider{}
|
||||
}
|
||||
|
||||
func (genAIProjectionProvider) Columns() []TraceColumn {
|
||||
// Key definitions (name/context/type) live once in GenAIFieldDefinitions, shared
|
||||
// with the metadata enrichment. Copy to locals so we can take their address.
|
||||
reqModel := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIRequestModel]
|
||||
inTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageInputTokens]
|
||||
outTok := telemetrytypes.GenAIFieldDefinitions[telemetrytypes.GenAIUsageOutputTokens]
|
||||
|
||||
cols := CommonTraceColumns()
|
||||
return append(cols,
|
||||
// LLM calls only (request model present), not the full gate (tool/agent too).
|
||||
TraceColumn{Alias: "llm_call_count", Orderable: true, Attr: &AttrAgg{Func: "count", ExistsKey: &reqModel}},
|
||||
// tokens live only on LLM spans, so a plain sum over resolved values (NULL
|
||||
// elsewhere) is correct without scoping to the mask. Aliases match the OTel
|
||||
// source attributes (gen_ai.usage.input_tokens / output_tokens).
|
||||
TraceColumn{Alias: "input_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &inTok}},
|
||||
TraceColumn{Alias: "output_tokens", Orderable: true, Attr: &AttrAgg{Func: "sum", ValueKey: &outTok}},
|
||||
// timestamp of the last in-scope (gen_ai: LLM/tool/agent) span.
|
||||
TraceColumn{Alias: "last_activity_time", Orderable: true, Attr: &AttrAgg{Func: "max", ValueExpr: "timestamp", Scoped: true}},
|
||||
)
|
||||
}
|
||||
|
||||
func (genAIProjectionProvider) DefaultOrderAlias() string { return "last_activity_time" }
|
||||
|
||||
func (genAIProjectionProvider) AggregateAliases() []string {
|
||||
// every computed column except service.name (a real resource key, filterable
|
||||
// at the span level). Of these, only the gen_ai-scoped ones (llm_call_count,
|
||||
// input_tokens, output_tokens, last_activity_time) are Orderable and thus usable
|
||||
// in ORDER BY / the aggregate filter; the rest are output-only.
|
||||
return []string{
|
||||
"start_time", "end_time", "duration_nano", "span_count", "root_span_name",
|
||||
"llm_call_count", "input_tokens", "output_tokens", "last_activity_time",
|
||||
}
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for source=ai")
|
||||
)
|
||||
|
||||
// scopedTraceStatementBuilder builds a trace list scoped to a span-selection
|
||||
// category. Topology is fixed; selection (BaseConditionProvider) and columns
|
||||
// (ProjectionProvider) are pluggable, so a new category is a new pair of
|
||||
// providers, not new topology.
|
||||
type scopedTraceStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
baseCond BaseConditionProvider
|
||||
projection ProjectionProvider
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
|
||||
|
||||
// NewScopedTraceStatementBuilder wires the generic trace-list builder. The trace
|
||||
// builder is reused for the span-list (raw) path.
|
||||
func NewScopedTraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
baseCond BaseConditionProvider,
|
||||
projection ProjectionProvider,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
) *scopedTraceStatementBuilder {
|
||||
aiSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryai")
|
||||
return &scopedTraceStatementBuilder{
|
||||
logger: aiSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
baseCond: baseCond,
|
||||
projection: projection,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAITraceStatementBuilder is the scoped builder with the gen_ai gate + AI projection.
|
||||
func NewAITraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
baseCond BaseConditionProvider,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
) *scopedTraceStatementBuilder {
|
||||
return NewScopedTraceStatementBuilder(settings, metadataStore, fieldMapper, conditionBuilder, baseCond, NewGenAIProjectionProvider(), traceStmtBuilder)
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceListQuery(ctx, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
}
|
||||
|
||||
// buildDelegated ANDs the base gate into the user filter and delegates to the
|
||||
// standard trace builder (the span-list / raw path).
|
||||
func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
ctx context.Context,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
gate := b.baseCond.FilterExpression()
|
||||
expr := gate
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
return b.traceStmtBuilder.Build(ctx, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// buildTraceListQuery is the map for the whole file. It resolves the columns, then
|
||||
// wires the CTE pipeline that was benchmarked (see ai-qb-handoff.md): a single
|
||||
// windowed pass picks the top-N traces, then a bucket-pruned pass enriches only those.
|
||||
// The helpers appear in this file in the order they run here.
|
||||
//
|
||||
// RESOLVE (turn keys/columns into SQL, field-mapper aware)
|
||||
// fetchKeys → metadata for the keys we reference
|
||||
// resolveMask → the "is a gen_ai span" predicate (OR of EXISTS) [existsExpr]
|
||||
// resolveColumns → per-trace column SQL: intrinsics + resolved aggregates
|
||||
// resolveListOrders→ which resolved columns to ORDER BY
|
||||
// splitFilter → span-level predicate + trace-level HAVING expression
|
||||
//
|
||||
// BUILD (compose the CTE pipeline)
|
||||
// matched [buildMatchedCTE] ONE windowed, mask-pruned GROUP BY trace_id pass over
|
||||
// │ the span index that applies the gate (+ span filter as
|
||||
// │ countIf existence), the trace-level HAVING, ORDER BY and
|
||||
// │ LIMIT/OFFSET in a single scan → top-N trace_ids + their
|
||||
// │ gen_ai-scoped ranking metrics. No giant gate id-set.
|
||||
// ▼
|
||||
// ranked [buildRankedCTE] per-trace [start,end] bounds for those N traces, read
|
||||
// │ from the small distributed_trace_summary table.
|
||||
// ▼
|
||||
// buckets [buildBucketsCTE] the exact ts_bucket_start values those N traces touch,
|
||||
// │ so the enrichment scan is primary-key pruned.
|
||||
// ▼
|
||||
// enrichment[buildEnrichmentSelect] all per-trace columns for the N traces, scanning
|
||||
// only their buckets (full trace, not window-clipped).
|
||||
//
|
||||
// Only gen_ai-scoped aggregates (tokens, llm activity, llm_call_count) are computable in
|
||||
// the mask-pruned `matched` pass, so only those are orderable / usable in the aggregate
|
||||
// filter. All-span columns (span_count, duration_nano, …) are output-only.
|
||||
//
|
||||
// start/end are nanoseconds.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
ctx context.Context,
|
||||
start, end uint64,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Resolve the gate keys + columns once; every attribute access below goes through
|
||||
// the field mapper (materialization/evolution aware), never a hardcoded map lookup.
|
||||
keys, err := b.fetchKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maskExpr, maskArgs, err := b.resolveMask(ctx, start, end, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := b.resolveColumns(ctx, start, end, keys, maskExpr, maskArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := b.resolveListOrders(query.Order, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
// Split the user filter: span-level predicate + trace-level HAVING expression.
|
||||
fp, err := b.splitFilter(ctx, query, b.aggregateAliasSet(), orderableSet, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// matched → ranked → buckets → enrichment
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, maskArgs, fp, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
bucketsFrag := buildBucketsCTE()
|
||||
mainSQL, mainArgs := b.buildEnrichmentSelect(resolved, orders)
|
||||
|
||||
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
|
||||
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
|
||||
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: fp.warnings,
|
||||
WarningsDocURL: fp.warningsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RESOLVE — turn keys/columns into field-mapper-aware SQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
Signal: k.Signal,
|
||||
FieldContext: k.FieldContext,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
seen := make(map[string]struct{})
|
||||
var out []*telemetrytypes.TelemetryFieldKey
|
||||
add := func(k *telemetrytypes.TelemetryFieldKey) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[k.Name]; dup {
|
||||
return
|
||||
}
|
||||
seen[k.Name] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range b.baseCond.FieldKeys() {
|
||||
add(k)
|
||||
}
|
||||
for _, c := range b.projection.Columns() {
|
||||
if c.Attr != nil {
|
||||
add(c.Attr.ValueKey)
|
||||
add(c.Attr.ExistsKey)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveMask builds the per-span in-scope mask: OR of resolved EXISTS predicates
|
||||
// over the base condition's field keys.
|
||||
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error) {
|
||||
fieldKeys := b.baseCond.FieldKeys()
|
||||
parts := make([]string, 0, len(fieldKeys))
|
||||
var args []any
|
||||
for _, key := range fieldKeys {
|
||||
e, a, err := b.existsExpr(ctx, start, end, keys, key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
parts = append(parts, e)
|
||||
args = append(args, a...)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args, nil
|
||||
}
|
||||
|
||||
// existsExpr resolves a field-mapper-aware EXISTS predicate for key (materialized
|
||||
// column when present, else the map). Escaped once so it round-trips when embedded
|
||||
// in an outer builder.
|
||||
func (b *scopedTraceStatementBuilder) existsExpr(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, key *telemetrytypes.TelemetryFieldKey) (string, []any, error) {
|
||||
resolvedKey := key
|
||||
cands := keys[key.Name]
|
||||
if len(cands) == 0 {
|
||||
cands = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
} else {
|
||||
resolvedKey = cands[0]
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := b.cb.ConditionFor(ctx, start, end, resolvedKey, cands, qbtypes.FilterOperatorExists, nil, sb)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.Where(conds[0])
|
||||
expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
expr = strings.TrimPrefix(expr, "WHERE ")
|
||||
return sqlbuilder.Escape(expr), args, nil
|
||||
}
|
||||
|
||||
// resolvedColumn is a projection column whose attribute access has been resolved to
|
||||
// SQL via the field mapper. expr is escaped once, ready to embed in an outer SELECT.
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
args []any
|
||||
orderable bool
|
||||
}
|
||||
|
||||
// resolveColumns turns the projection's declarative columns into SQL, resolving all
|
||||
// attribute access through the field mapper.
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, maskExpr string, maskArgs []any) ([]resolvedColumn, error) {
|
||||
cols := b.projection.Columns()
|
||||
out := make([]resolvedColumn, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
rc := resolvedColumn{alias: c.Alias, orderable: c.Orderable}
|
||||
|
||||
if c.Attr == nil {
|
||||
// intrinsic: escape once (no-op unless it references a $$ column).
|
||||
rc.expr = sqlbuilder.Escape(c.Intrinsic)
|
||||
out = append(out, rc)
|
||||
continue
|
||||
}
|
||||
|
||||
a := c.Attr
|
||||
switch {
|
||||
case a.Func == "count" && a.ExistsKey != nil:
|
||||
cond, cargs, err := b.existsExpr(ctx, start, end, keys, a.ExistsKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.expr = fmt.Sprintf("countIf(%s)", cond)
|
||||
rc.args = cargs
|
||||
default:
|
||||
var vexpr string
|
||||
var vargs []any
|
||||
if a.ValueKey != nil {
|
||||
e, ar, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, a.ValueKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeFloat64, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vexpr, vargs = e, ar
|
||||
} else {
|
||||
vexpr = a.ValueExpr
|
||||
}
|
||||
if a.Scoped {
|
||||
rc.expr = fmt.Sprintf("%sIf(%s, %s)", a.Func, vexpr, maskExpr)
|
||||
rc.args = append(append([]any{}, vargs...), maskArgs...)
|
||||
} else {
|
||||
rc.expr = fmt.Sprintf("%s(%s)", a.Func, vexpr)
|
||||
rc.args = vargs
|
||||
}
|
||||
}
|
||||
out = append(out, rc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listOrder resolves a sort key to an aggregate-column alias + direction. Both the
|
||||
// matched CTE and the enrichment select that alias, so both ORDER BY it.
|
||||
type listOrder struct {
|
||||
alias string
|
||||
direction string
|
||||
}
|
||||
|
||||
// resolveListOrders maps order keys to the resolved orderable columns; non-orderable
|
||||
// columns are rejected. Defaults to the projection's default order.
|
||||
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
|
||||
byAlias := make(map[string]resolvedColumn, len(resolved))
|
||||
orderable := make([]string, 0, len(resolved))
|
||||
for _, rc := range resolved {
|
||||
byAlias[rc.alias] = rc
|
||||
if rc.orderable {
|
||||
orderable = append(orderable, rc.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return []listOrder{{alias: b.projection.DefaultOrderAlias(), direction: "DESC"}}, nil
|
||||
}
|
||||
|
||||
orders := make([]listOrder, 0, len(order))
|
||||
for _, o := range order {
|
||||
direction := "DESC"
|
||||
if o.Direction == qbtypes.OrderDirectionAsc {
|
||||
direction = "ASC"
|
||||
}
|
||||
rc, ok := byAlias[o.Key.Name]
|
||||
if !ok || !rc.orderable {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
|
||||
}
|
||||
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the split of the user filter into a resolved span-level predicate
|
||||
// (used both to widen the matched WHERE prune and as a countIf existence in HAVING)
|
||||
// and a trace-level HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
spanArgs []any
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter partitions query.Filter into a span-level predicate and a trace-level
|
||||
// HAVING expression; an explicit query.Having is ANDed onto the latter. The trace-level
|
||||
// expression is validated against the aggregates computable in the matched pass.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem) (filterParts, error) {
|
||||
var fp filterParts
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, args, warnings, url, err := b.resolveSpanPredicate(ctx, start, end, spanExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.spanPred, fp.spanArgs, fp.hasSpanFilter = pred, args, true
|
||||
fp.warnings, fp.warningsURL = warnings, url
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean SQL
|
||||
// predicate (escaped) + args via the field mapper.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, start, end uint64, expr string, variables map[string]qbtypes.VariableItem) (string, []any, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, selectors)
|
||||
if err != nil {
|
||||
return "", nil, nil, "", err
|
||||
}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: b.logger,
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, nil, "", err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return "", nil, nil, "", nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("1")
|
||||
sb.AddWhereClause(prepared.WhereClause)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
pred := sql[strings.Index(sql, "WHERE ")+len("WHERE "):]
|
||||
return sqlbuilder.Escape(pred), args, prepared.Warnings, prepared.WarningsDocURL, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUILD — compose the CTE pipeline (matched → ranked → buckets → enrichment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildMatchedCTE builds `matched`: a single windowed GROUP BY trace_id pass that
|
||||
// picks the top-N traces. The WHERE prunes to gen_ai spans (widened with the span-level
|
||||
// predicate when present); HAVING enforces the gate (countIf(mask) > 0), the span-level
|
||||
// filter as an existence check, and the trace-level aggregate filter; ORDER BY + LIMIT
|
||||
// select the winners. Only gen_ai-scoped aggregates are computable here, and only those
|
||||
// actually referenced by ORDER BY / the aggregate filter are selected (the rest are
|
||||
// computed once, later, in the enrichment scan) — this keeps the hot ranking pass lean.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, maskArgs []any, fp filterParts, limit, offset int) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// SELECT trace_id + only the aggregates ORDER BY / HAVING reference (as aliases).
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, embedExpr(sb, rc.expr, rc.args)+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(spanTable())
|
||||
|
||||
// WHERE: window + coarse prune to gen_ai spans (widened so span-filter spans are
|
||||
// visible for the countIf existence check below).
|
||||
win := windowWhere(sb, start, end, startBucket, endBucket)
|
||||
prune := "(" + embedExpr(sb, maskExpr, maskArgs)
|
||||
if fp.hasSpanFilter {
|
||||
prune += " OR " + embedExpr(sb, fp.spanPred, fp.spanArgs)
|
||||
}
|
||||
prune += ")"
|
||||
sb.Where(append(win, prune)...)
|
||||
sb.GroupBy("trace_id")
|
||||
|
||||
// HAVING: the gate + span-existence checks are only needed once the WHERE has been
|
||||
// widened by a span filter; otherwise WHERE = mask already enforces the gate.
|
||||
var having []string
|
||||
if fp.hasSpanFilter {
|
||||
having = append(having, "countIf("+embedExpr(sb, maskExpr, maskArgs)+") > 0")
|
||||
having = append(having, "countIf("+embedExpr(sb, fp.spanPred, fp.spanArgs)+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
hv, err := b.buildHaving(fp.havingExpr, orderableSet)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
having = append(having, hv)
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
}
|
||||
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sb.Limit(limit)
|
||||
if offset > 0 {
|
||||
sb.Offset(offset)
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: per-trace [start,end] bounds for the matched traces,
|
||||
// read from the small trace-summary table (used to derive the bucket prune).
|
||||
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
|
||||
sb.From(summaryTable())
|
||||
sb.Where(
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
|
||||
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
|
||||
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("ranked AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildBucketsCTE builds `buckets`: the exact ts_bucket_start values the matched traces
|
||||
// span, so the enrichment scan is pruned to those primary-key buckets. No args.
|
||||
func buildBucketsCTE() string {
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
return fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
|
||||
"ARRAY JOIN range("+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
|
||||
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
|
||||
}
|
||||
|
||||
// buildEnrichmentSelect builds the final SELECT: all per-trace columns for the matched
|
||||
// traces, scanning only their buckets (full trace, not window-clipped). SELECT-expr
|
||||
// args lead; the WHERE / ORDER BY carry none.
|
||||
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(resolved []resolvedColumn, orders []listOrder) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects, selectArgs := selectAllColumns(resolved)
|
||||
sb.Select(selects...)
|
||||
sb.From(spanTable())
|
||||
sb.Where(
|
||||
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sql, builtArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return sql, append(append([]any{}, selectArgs...), builtArgs...)
|
||||
}
|
||||
|
||||
// buildHaving rewrites a trace-level HAVING expression against the aggregate column
|
||||
// aliases computable in the matched pass; bare, trace., and tracefield. forms all map
|
||||
// to the selected alias.
|
||||
func (b *scopedTraceStatementBuilder) buildHaving(havingExpr string, orderableSet map[string]struct{}) (string, error) {
|
||||
columnMap := make(map[string]string, len(orderableSet)*3)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap["trace."+a] = quoteAlias(a)
|
||||
columnMap["tracefield."+a] = quoteAlias(a)
|
||||
}
|
||||
return querybuilder.NewHavingExpressionRewriter().Rewrite(havingExpr, columnMap)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small shared SQL-builder utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// spanTable is the fully-qualified span index table.
|
||||
func spanTable() string {
|
||||
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
|
||||
}
|
||||
|
||||
// summaryTable is the fully-qualified trace-summary table.
|
||||
func summaryTable() string {
|
||||
return fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.TraceSummaryTableName)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is the set of all trace-level (computed) column aliases, used to
|
||||
// classify which filter keys are trace-level vs span-level.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.projection.AggregateAliases()))
|
||||
for _, a := range b.projection.AggregateAliases() {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aggregate aliases computable in the matched pass
|
||||
// (gen_ai-scoped): the only ones usable for ORDER BY and the aggregate filter.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal set of aggregate aliases the matched pass must
|
||||
// select: those referenced by ORDER BY plus those referenced in the aggregate HAVING
|
||||
// (bare / trace. / tracefield. forms). Anything else is left to the enrichment scan.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter that references an aggregate not
|
||||
// computable in the matched pass (e.g. span_count, duration_nano), with a clear message.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(sel.Name, "trace."), "tracefield.")
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in an AI trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedExpr inlines a resolved (escaped) expr carrying `?` placeholders into sb by
|
||||
// replacing each `?` with a builder Var, so go-sqlbuilder tracks the args in appearance
|
||||
// order and un-escapes the expr at Build time.
|
||||
func embedExpr(sb *sqlbuilder.SelectBuilder, expr string, args []any) string {
|
||||
var out strings.Builder
|
||||
ai := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
if expr[i] == '?' && ai < len(args) {
|
||||
out.WriteString(sb.Var(args[ai]))
|
||||
ai++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(expr[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// windowWhere binds the shared time-window predicates to sb and returns them, so a
|
||||
// caller can add its own predicate in the same Where call.
|
||||
func windowWhere(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64) []string {
|
||||
return []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
}
|
||||
}
|
||||
|
||||
// orderClause renders the ORDER BY terms (by column alias) + the trace_id tiebreak.
|
||||
func orderClause(orders []listOrder) []string {
|
||||
out := make([]string, 0, len(orders)+1)
|
||||
for _, o := range orders {
|
||||
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
|
||||
}
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// selectAllColumns renders `expr AS alias` for every resolved column and returns their
|
||||
// field-mapper args in select order.
|
||||
func selectAllColumns(resolved []resolvedColumn) ([]string, []any) {
|
||||
selects := []string{"trace_id"}
|
||||
var args []any
|
||||
for _, rc := range resolved {
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
args = append(args, rc.args...)
|
||||
}
|
||||
return selects, args
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias that carries characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
return "`" + alias + "`"
|
||||
}
|
||||
return alias
|
||||
}
|
||||
@@ -1,705 +0,0 @@
|
||||
package telemetryai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// otelKeysMap seeds the OpenTelemetry gen_ai semantic-convention keys the AI
|
||||
// queries reference, so the metadata-backed field resolution succeeds in tests.
|
||||
func otelKeysMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
strKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
numKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
}
|
||||
}
|
||||
|
||||
m := make(map[string][]*telemetrytypes.TelemetryFieldKey)
|
||||
|
||||
// gen_ai semconv keys sourced from the single source of truth, mirroring what the
|
||||
// production metadata store surfaces via enrichWithGenAIKeys.
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
keyCopy := def
|
||||
m[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
|
||||
// Extra keys these tests reference that aren't gen_ai semconv definitions.
|
||||
m["gen_ai.user.id"] = []*telemetrytypes.TelemetryFieldKey{strKey("gen_ai.user.id")}
|
||||
m["gen_ai.usage.cost"] = []*telemetrytypes.TelemetryFieldKey{numKey("gen_ai.usage.cost")}
|
||||
m["gen_ai.usage.cached_input_tokens"] = []*telemetrytypes.TelemetryFieldKey{numKey("gen_ai.usage.cached_input_tokens")}
|
||||
m["has_error"] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: "has_error",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
}}
|
||||
return m
|
||||
}
|
||||
|
||||
// standard test window (ms), matching the traces builder tests.
|
||||
const (
|
||||
testStartMs = uint64(1747947419000)
|
||||
testEndMs = uint64(1747983448000)
|
||||
)
|
||||
|
||||
func newTestBuilder(t *testing.T) *scopedTraceStatementBuilder {
|
||||
return newTestBuilderWithKeys(t, otelKeysMap())
|
||||
}
|
||||
|
||||
// newTestBuilderWithKeys mirrors the production wiring in signozquerier's provider.
|
||||
// The gen_ai keys are seeded via keysMap here; in production the metadata store
|
||||
// surfaces them itself (enrichWithGenAIKeys).
|
||||
func newTestBuilderWithKeys(t *testing.T, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) *scopedTraceStatementBuilder {
|
||||
t.Helper()
|
||||
settings := instrumentationtest.New().ToProviderSettings()
|
||||
fm := telemetrytraces.NewFieldMapper()
|
||||
cb := telemetrytraces.NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = keysMap
|
||||
fl := flaggertest.New(t)
|
||||
baseCond := NewGenAIBaseConditionProvider()
|
||||
// In production the metadata store enriches gen_ai keys (enrichWithGenAIKeys);
|
||||
// here the mock is seeded directly via keysMap.
|
||||
metadataStore := telemetrytypes.MetadataStore(mockMetadataStore)
|
||||
rewriter := querybuilder.NewAggExprRewriter(settings, nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
|
||||
settings,
|
||||
metadataStore,
|
||||
fm,
|
||||
cb,
|
||||
rewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
return NewAITraceStatementBuilder(
|
||||
settings,
|
||||
metadataStore,
|
||||
fm,
|
||||
cb,
|
||||
baseCond,
|
||||
traceStmtBuilder,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-query golden tests
|
||||
//
|
||||
// Each pins the WHOLE generated statement, with bound args inlined into the `?`
|
||||
// placeholders, as ONE self-contained literal — so a failure diff shows the entire
|
||||
// query and the expected SQL can be copied straight into a ClickHouse client. The
|
||||
// `want` strings are formatted for readability; the comparison is whitespace- and
|
||||
// backtick-insensitive (see normalizeSQL), so only the SQL tokens themselves matter.
|
||||
//
|
||||
// The four trace-list goldens cover the corners of how `matched` is assembled —
|
||||
// {no span filter, span filter} × {no aggregate filter, aggregate filter} — plus a
|
||||
// mixed filter + multi-key order, plus the delegated span list. Note `matched` selects
|
||||
// only the aggregates ORDER BY / HAVING reference; the rest appear only in enrichment.
|
||||
//
|
||||
// Run `go test ./pkg/telemetryai/ -run TestBuild_FullSQL -v` to also print each query.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderSQL substitutes bound args into the `?` placeholders so the whole statement
|
||||
// reads as one literal SQL string.
|
||||
func renderSQL(t *testing.T, stmt *qbtypes.Statement) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
argi := 0
|
||||
for i := 0; i < len(stmt.Query); i++ {
|
||||
if stmt.Query[i] == '?' {
|
||||
require.Less(t, argi, len(stmt.Args), "more ? than args in query")
|
||||
b.WriteString(formatArg(stmt.Args[argi]))
|
||||
argi++
|
||||
continue
|
||||
}
|
||||
b.WriteByte(stmt.Query[i])
|
||||
}
|
||||
require.Equal(t, len(stmt.Args), argi, "arg count does not match number of placeholders")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatArg(a any) string {
|
||||
if s, ok := a.(string); ok {
|
||||
return "'" + s + "'"
|
||||
}
|
||||
return fmt.Sprintf("%v", a)
|
||||
}
|
||||
|
||||
// normalizeSQL makes the comparison insensitive to formatting: it drops identifier
|
||||
// backticks, collapses whitespace runs to a single space, and removes spaces directly
|
||||
// inside parentheses. This lets the golden strings be freely indented/wrapped (and
|
||||
// written as Go raw literals, which cannot contain backticks) — only the SQL tokens
|
||||
// and their order matter.
|
||||
func normalizeSQL(s string) string {
|
||||
s = strings.Join(strings.Fields(strings.ReplaceAll(s, "`", "")), " ")
|
||||
s = strings.ReplaceAll(s, "( ", "(")
|
||||
s = strings.ReplaceAll(s, " )", ")")
|
||||
return s
|
||||
}
|
||||
|
||||
func requireSQLEqual(t *testing.T, want string, stmt *qbtypes.Statement) {
|
||||
t.Helper()
|
||||
got := renderSQL(t, stmt)
|
||||
t.Logf("\n%s", got)
|
||||
require.Equal(t, normalizeSQL(want), normalizeSQL(got))
|
||||
}
|
||||
|
||||
// No filter: matched selects only the default order key (last_activity_time), WHERE is
|
||||
// just window + gate mask, no HAVING.
|
||||
func TestBuild_FullSQL_TraceList_NoFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI, Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span-level AND trace-level filter, order by the aggregate, pagination. matched selects
|
||||
// only output_tokens (the sole aggregate referenced by both ORDER BY and HAVING) — not
|
||||
// input_tokens/llm_call_count/last_activity_time. The span predicate widens the WHERE
|
||||
// prune and becomes a countIf(...) > 0 existence check alongside the gate countIf.
|
||||
func TestBuild_FullSQL_TraceList_SpanAndTraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND output_tokens > 1000"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "output_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 10, Offset: 30,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model') = true))
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model') = true)) > 0
|
||||
AND output_tokens > 1000
|
||||
ORDER BY output_tokens DESC, trace_id DESC
|
||||
LIMIT 10 OFFSET 30
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY output_tokens DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Aggregate-only filter (no span filter). WHERE prune is NOT widened, there is no
|
||||
// gate/span countIf, just the aggregate HAVING. `trace.output_tokens` rewrites to the
|
||||
// output_tokens alias. matched selects output_tokens (HAVING) + last_activity_time (default order).
|
||||
func TestBuild_FullSQL_TraceList_AggregateFilterOnly(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span-only filter (no aggregate filter). WHERE is widened; HAVING has the gate + span
|
||||
// countIf pair but no trailing aggregate. `has_error = true` resolves to a
|
||||
// materialized-column predicate (not a map access). matched selects only the default order key.
|
||||
func TestBuild_FullSQL_TraceList_SpanFilterOnly(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "has_error = true"},
|
||||
Limit: 20,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR has_error = true)
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf(has_error = true) > 0
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Mixed filter (two span predicates AND'd into one existence check + an aggregate) with
|
||||
// a two-key order on different aggregates than the filter. matched selects input_tokens
|
||||
// + last_activity_time (ORDER BY) and output_tokens (HAVING) — three of four; llm_call_count is not.
|
||||
func TestBuild_FullSQL_TraceList_MixedFiltersMultiOrder(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o' AND has_error = true AND output_tokens > 500"},
|
||||
Order: []qbtypes.OrderBy{
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "input_tokens"}}, Direction: qbtypes.OrderDirectionDesc},
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "last_activity_time"}}, Direction: qbtypes.OrderDirectionAsc},
|
||||
},
|
||||
Limit: 15,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)
|
||||
OR ((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model') = true) AND has_error = true))
|
||||
GROUP BY trace_id
|
||||
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) > 0
|
||||
AND countIf(((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model') = true) AND has_error = true)) > 0
|
||||
AND output_tokens > 500
|
||||
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
|
||||
LIMIT 15
|
||||
),
|
||||
ranked AS (
|
||||
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
|
||||
FROM signoz_traces.distributed_trace_summary
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
|
||||
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
|
||||
AND start < fromUnixTimestamp64Nano(1747983448000000000)
|
||||
GROUP BY trace_id
|
||||
),
|
||||
buckets AS (
|
||||
SELECT DISTINCT b AS ts_bucket
|
||||
FROM ranked
|
||||
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
|
||||
)
|
||||
SELECT trace_id,
|
||||
min(timestamp) AS start_time,
|
||||
max(timestamp) AS end_time,
|
||||
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS duration_nano,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(resource_string_service$$name) AS service.name,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model') = true) AS llm_call_count,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens') = true, toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens') = true, toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') = true OR mapContains(attributes_string, 'gen_ai.tool.name') = true OR mapContains(attributes_string, 'gen_ai.agent.name') = true)) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
|
||||
GROUP BY trace_id
|
||||
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Span list (requestType raw): delegated to the traces builder with the gate ANDed
|
||||
// into the user filter, so only gen_ai spans matching the filter come back. Standard
|
||||
// span columns, single SELECT (no CTE pipeline).
|
||||
func TestBuild_FullSQL_SpanList_Raw(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeRaw,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
|
||||
Limit: 10,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireSQLEqual(t, `
|
||||
SELECT timestamp AS timestamp, trace_id AS trace_id, span_id AS span_id,
|
||||
trace_state AS trace_state, parent_span_id AS parent_span_id, flags AS flags,
|
||||
name AS name, kind AS kind, kind_string AS kind_string, duration_nano AS duration_nano,
|
||||
status_code AS status_code, status_message AS status_message,
|
||||
status_code_string AS status_code_string, events AS events, links AS links,
|
||||
response_status_code AS response_status_code, external_http_url AS external_http_url,
|
||||
http_url AS http_url, external_http_method AS external_http_method,
|
||||
http_method AS http_method, http_host AS http_host, db_name AS db_name,
|
||||
db_operation AS db_operation, has_error AS has_error, is_remote AS is_remote,
|
||||
attributes_string, attributes_number, attributes_bool, resources_string
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE (((mapContains(attributes_string, 'gen_ai.request.model') = true
|
||||
OR mapContains(attributes_string, 'gen_ai.tool.name') = true
|
||||
OR mapContains(attributes_string, 'gen_ai.agent.name') = true))
|
||||
AND ((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini'
|
||||
AND mapContains(attributes_string, 'gen_ai.request.model') = true)))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
LIMIT 10
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavior / branch tests not covered by the goldens above
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trace-level and span-level predicates may not be OR-combined.
|
||||
func TestBuild_TraceList_TraceOrSpanMixRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000 OR gen_ai.request.model = 'x'"},
|
||||
Limit: 10,
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot be combined")
|
||||
}
|
||||
|
||||
// An output-only aggregate (span_count / duration_nano) can be displayed but not used
|
||||
// in the aggregate filter or ORDER BY — it is not computable in the matched pass.
|
||||
func TestBuild_TraceList_OutputOnlyAggregateRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
|
||||
// filter by span_count -> rejected
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: "span_count > 3"},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "span_count")
|
||||
|
||||
// order by duration_nano -> rejected
|
||||
_, err = b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "duration_nano"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported order key")
|
||||
}
|
||||
|
||||
// A HAVING referencing a non-aggregate column is rejected.
|
||||
func TestBuild_TraceList_Having_UnknownColumn(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Having: &qbtypes.Having{Expression: "service.name > 1"}, // not an aggregate column
|
||||
Limit: 10,
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Ordering by an unknown key is rejected.
|
||||
func TestBuild_TraceList_UnsupportedOrderKey(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "http.request.method"}}, Direction: qbtypes.OrderDirectionDesc},
|
||||
},
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported order key")
|
||||
}
|
||||
|
||||
// With no limit set, the builder applies the default of 100.
|
||||
func TestBuild_TraceList_DefaultLimit(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
}
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "LIMIT ?")
|
||||
require.Contains(t, stmt.Args, 100)
|
||||
}
|
||||
|
||||
// Only trace list and span list (raw) are supported; distribution is not.
|
||||
func TestBuild_UnsupportedRequestType(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Source: telemetrytypes.SourceAI,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
}
|
||||
_, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeDistribution, query, nil)
|
||||
require.ErrorIs(t, err, ErrUnsupportedRequestType)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter operator resolution
|
||||
//
|
||||
// The goldens pin the CTE structure; these pin how each filter OPERATOR resolves into
|
||||
// SQL (the part that varies with the operator, not the pipeline). Span-level operators
|
||||
// resolve to a predicate that appears both in the widened WHERE prune and as a
|
||||
// countIf(...) > 0 existence check; aggregate operators become a HAVING (values inlined).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuild_TraceList_SpanFilterOperatorResolution(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(t *testing.T, expr string) string {
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: expr}, Limit: 5,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
return stmt.Query
|
||||
}
|
||||
|
||||
cases := []struct{ name, expr, frag string }{
|
||||
{"not_equal", "gen_ai.request.model != 'gpt-4o'",
|
||||
"attributes_string['gen_ai.request.model'] <> ?"},
|
||||
{"in", "gen_ai.request.model IN ('gpt-4o', 'gpt-4')",
|
||||
"(attributes_string['gen_ai.request.model'] = ? OR attributes_string['gen_ai.request.model'] = ?) AND mapContains(attributes_string, 'gen_ai.request.model') = ?"},
|
||||
{"exists", "gen_ai.user.id EXISTS", // non-gate key, so the fragment is unambiguous
|
||||
"mapContains(attributes_string, 'gen_ai.user.id') = ?"},
|
||||
{"not_exists", "gen_ai.user.id NOT EXISTS",
|
||||
"mapContains(attributes_string, 'gen_ai.user.id') <> ?"},
|
||||
{"contains", "gen_ai.request.model CONTAINS 'gpt'", // case-insensitive
|
||||
"LOWER(attributes_string['gen_ai.request.model']) LIKE LOWER(?)"},
|
||||
{"like", "gen_ai.request.model LIKE 'gpt%'",
|
||||
"attributes_string['gen_ai.request.model'] LIKE ?"},
|
||||
{"numeric_gte", "gen_ai.usage.output_tokens >= 100", // span attr (Float64), distinct from the output_tokens aggregate
|
||||
"toFloat64(attributes_number['gen_ai.usage.output_tokens']) >= ? AND mapContains(attributes_number, 'gen_ai.usage.output_tokens') = ?"},
|
||||
{"not_group", "NOT (gen_ai.request.model = 'gpt-4o')",
|
||||
"NOT (((attributes_string['gen_ai.request.model'] = ? AND mapContains(attributes_string, 'gen_ai.request.model') = ?)))"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// the resolved predicate appears in the widened WHERE prune and (wrapped) in
|
||||
// the countIf existence check; the goldens pin the countIf structure, here we
|
||||
// pin only the operator's resolution.
|
||||
require.Contains(t, build(t, c.expr), c.frag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_TraceList_AggregateFilterOperatorResolution(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(t *testing.T, expr string) (string, error) {
|
||||
stmt, err := b.Build(context.Background(), testStartMs, testEndMs, qbtypes.RequestTypeTrace,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces, Source: telemetrytypes.SourceAI,
|
||||
Filter: &qbtypes.Filter{Expression: expr}, Limit: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stmt.Query, nil
|
||||
}
|
||||
|
||||
// values are inlined by the HAVING rewriter (not parameterized).
|
||||
cases := []struct{ name, expr, having string }{
|
||||
{"less_than", "output_tokens < 500", "HAVING output_tokens < 500"},
|
||||
{"not_equal", "output_tokens != 0", "HAVING output_tokens != 0"},
|
||||
{"range_and", "output_tokens >= 500 AND output_tokens <= 1000", "HAVING (output_tokens >= 500 AND output_tokens <= 1000)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := build(t, c.expr)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, q, c.having)
|
||||
})
|
||||
}
|
||||
|
||||
// BETWEEN is not supported by the HAVING rewriter — surfaced as an error, not silently wrong.
|
||||
t.Run("between_unsupported", func(t *testing.T) {
|
||||
_, err := build(t, "output_tokens BETWEEN 500 AND 1000")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1190,27 +1190,6 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
|
||||
return keys
|
||||
}
|
||||
|
||||
// enrichWithGenAIKeys adds keys that can be queried for GenAI signals, even though they have not been ingested yet.
|
||||
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
for _, selector := range selectors {
|
||||
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
|
||||
continue
|
||||
}
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
if len(keys[name]) > 0 {
|
||||
continue // already resolved from ingested data
|
||||
}
|
||||
if !selectorMatchesIntrinsicField(selector, def) {
|
||||
continue
|
||||
}
|
||||
keyCopy := def
|
||||
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
|
||||
return false
|
||||
@@ -1296,7 +1275,6 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, fieldKeySelector *tele
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
@@ -1375,7 +1353,6 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, fieldKeySelectors
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
|
||||
@@ -9,17 +9,18 @@ type CheckType struct {
|
||||
}
|
||||
|
||||
var (
|
||||
CheckTypeHosts = CheckType{valuer.NewString("hosts")}
|
||||
CheckTypeProcesses = CheckType{valuer.NewString("processes")}
|
||||
CheckTypePods = CheckType{valuer.NewString("pods")}
|
||||
CheckTypeNodes = CheckType{valuer.NewString("nodes")}
|
||||
CheckTypeDeployments = CheckType{valuer.NewString("deployments")}
|
||||
CheckTypeDaemonsets = CheckType{valuer.NewString("daemonsets")}
|
||||
CheckTypeStatefulsets = CheckType{valuer.NewString("statefulsets")}
|
||||
CheckTypeJobs = CheckType{valuer.NewString("jobs")}
|
||||
CheckTypeNamespaces = CheckType{valuer.NewString("namespaces")}
|
||||
CheckTypeClusters = CheckType{valuer.NewString("clusters")}
|
||||
CheckTypeVolumes = CheckType{valuer.NewString("volumes")}
|
||||
CheckTypeHosts = CheckType{valuer.NewString("hosts")}
|
||||
CheckTypeProcesses = CheckType{valuer.NewString("processes")}
|
||||
CheckTypePods = CheckType{valuer.NewString("pods")}
|
||||
CheckTypeNodes = CheckType{valuer.NewString("nodes")}
|
||||
CheckTypeDeployments = CheckType{valuer.NewString("deployments")}
|
||||
CheckTypeDaemonsets = CheckType{valuer.NewString("daemonsets")}
|
||||
CheckTypeStatefulsets = CheckType{valuer.NewString("statefulsets")}
|
||||
CheckTypeJobs = CheckType{valuer.NewString("jobs")}
|
||||
CheckTypeNamespaces = CheckType{valuer.NewString("namespaces")}
|
||||
CheckTypeClusters = CheckType{valuer.NewString("clusters")}
|
||||
CheckTypeVolumes = CheckType{valuer.NewString("volumes")}
|
||||
CheckTypeKubeContainers = CheckType{valuer.NewString("kube_containers")}
|
||||
)
|
||||
|
||||
func (CheckType) Enum() []any {
|
||||
@@ -35,6 +36,7 @@ func (CheckType) Enum() []any {
|
||||
CheckTypeNamespaces,
|
||||
CheckTypeClusters,
|
||||
CheckTypeVolumes,
|
||||
CheckTypeKubeContainers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ var ValidCheckTypes = []CheckType{
|
||||
CheckTypeNamespaces,
|
||||
CheckTypeClusters,
|
||||
CheckTypeVolumes,
|
||||
CheckTypeKubeContainers,
|
||||
}
|
||||
|
||||
// CheckComponentType tags each AssociatedComponent as either a receiver or a processor.
|
||||
|
||||
@@ -84,6 +84,11 @@ func TestPostableChecks_Validate(t *testing.T) {
|
||||
req: &PostableChecks{Type: CheckTypeVolumes},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "kube_containers",
|
||||
req: &PostableChecks{Type: CheckTypeKubeContainers},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
148
pkg/types/inframonitoringtypes/containers.go
Normal file
148
pkg/types/inframonitoringtypes/containers.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Containers struct {
|
||||
Type ResponseType `json:"type" required:"true"`
|
||||
Records []ContainerRecord `json:"records" required:"true" nullable:"false"`
|
||||
Total int `json:"total" required:"true"`
|
||||
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerCountsByStatus buckets container counts by their latest kubectl-style
|
||||
// display status in the time window (see ContainerStatus). One field per
|
||||
// derivable status. Populated in both list and grouped_list modes.
|
||||
type ContainerCountsByStatus struct {
|
||||
// State fallback.
|
||||
Running int `json:"running" required:"true"`
|
||||
Waiting int `json:"waiting" required:"true"`
|
||||
Terminated int `json:"terminated" required:"true"`
|
||||
|
||||
// Container-level reasons (k8s.container.status.reason allowlist).
|
||||
CrashLoopBackOff int `json:"crashLoopBackOff" required:"true"`
|
||||
ImagePullBackOff int `json:"imagePullBackOff" required:"true"`
|
||||
ErrImagePull int `json:"errImagePull" required:"true"`
|
||||
CreateContainerConfigError int `json:"createContainerConfigError" required:"true"`
|
||||
ContainerCreating int `json:"containerCreating" required:"true"`
|
||||
OOMKilled int `json:"oomKilled" required:"true"`
|
||||
Completed int `json:"completed" required:"true"`
|
||||
Error int `json:"error" required:"true"`
|
||||
ContainerCannotRun int `json:"containerCannotRun" required:"true"`
|
||||
|
||||
Unknown int `json:"unknown" required:"true"`
|
||||
}
|
||||
|
||||
// ContainerCountsByReady buckets container counts by their latest readiness
|
||||
// (k8s.container.ready) in the time window. Populated in both modes.
|
||||
type ContainerCountsByReady struct {
|
||||
Ready int `json:"ready" required:"true"`
|
||||
NotReady int `json:"notReady" required:"true"`
|
||||
}
|
||||
|
||||
type ContainerRecord struct {
|
||||
// Row identity: (k8s.pod.uid, k8s.container.name). Stable across container
|
||||
// restarts (unlike container.id), globally unique, present on both receivers.
|
||||
PodUID string `json:"podUID" required:"true"`
|
||||
ContainerName string `json:"containerName" required:"true"`
|
||||
|
||||
// Health (k8sclusterreceiver). Single value in list mode; counts in grouped_list mode.
|
||||
Status ContainerStatus `json:"status" required:"true"`
|
||||
ContainerCountsByStatus ContainerCountsByStatus `json:"containerCountsByStatus" required:"true"`
|
||||
Ready ContainerReady `json:"ready" required:"true"`
|
||||
ContainerCountsByReady ContainerCountsByReady `json:"containerCountsByReady" required:"true"`
|
||||
// Absolute restart count: own count (list mode) or group sum (grouped mode). -1 when no data.
|
||||
Restarts int64 `json:"restarts" required:"true"`
|
||||
|
||||
// Usage / utilization (kubeletstats). -1 when no data.
|
||||
CPU float64 `json:"cpu" required:"true"` // container.cpu.usage (cores)
|
||||
CPURequestUtilization float64 `json:"cpuRequestUtilization" required:"true"` // k8s.container.cpu_request_utilization
|
||||
CPULimitUtilization float64 `json:"cpuLimitUtilization" required:"true"` // k8s.container.cpu_limit_utilization
|
||||
Memory float64 `json:"memory" required:"true"` // container.memory.working_set
|
||||
MemoryRequestUtilization float64 `json:"memoryRequestUtilization" required:"true"` // k8s.container.memory_request_utilization
|
||||
MemoryLimitUtilization float64 `json:"memoryLimitUtilization" required:"true"` // k8s.container.memory_limit_utilization
|
||||
|
||||
Meta map[string]string `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
// PostableContainers is the request body for the v2 containers list API.
|
||||
type PostableContainers struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableContainers contains acceptable values.
|
||||
func (req *PostableContainers) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid start time %d: start must be greater than 0",
|
||||
req.Start,
|
||||
)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid end time %d: end must be greater than 0",
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid time range: start (%d) must be less than end (%d)",
|
||||
req.Start,
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
if req.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(ContainersValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
}
|
||||
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
|
||||
}
|
||||
if req.OrderBy.Key.Name == ContainerNameAttrKey && len(req.GroupBy) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", ContainerNameAttrKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostableContainers) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableContainers
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableContainers(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
92
pkg/types/inframonitoringtypes/containers_constants.go
Normal file
92
pkg/types/inframonitoringtypes/containers_constants.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
// ContainerStatus is the kubectl-style display status of a container, derived
|
||||
// from k8s.container.status.state (base) + k8s.container.status.reason (overlay).
|
||||
type ContainerStatus struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
// State fallback (from k8s.container.status.state).
|
||||
ContainerStatusRunning = ContainerStatus{valuer.NewString("Running")}
|
||||
ContainerStatusWaiting = ContainerStatus{valuer.NewString("Waiting")}
|
||||
ContainerStatusTerminated = ContainerStatus{valuer.NewString("Terminated")}
|
||||
|
||||
// Reasons (from k8s.container.status.reason allowlist).
|
||||
ContainerStatusCrashLoopBackOff = ContainerStatus{valuer.NewString("CrashLoopBackOff")}
|
||||
ContainerStatusImagePullBackOff = ContainerStatus{valuer.NewString("ImagePullBackOff")}
|
||||
ContainerStatusErrImagePull = ContainerStatus{valuer.NewString("ErrImagePull")}
|
||||
ContainerStatusCreateContainerConfigError = ContainerStatus{valuer.NewString("CreateContainerConfigError")}
|
||||
ContainerStatusContainerCreating = ContainerStatus{valuer.NewString("ContainerCreating")}
|
||||
ContainerStatusOOMKilled = ContainerStatus{valuer.NewString("OOMKilled")}
|
||||
ContainerStatusCompleted = ContainerStatus{valuer.NewString("Completed")}
|
||||
ContainerStatusError = ContainerStatus{valuer.NewString("Error")}
|
||||
ContainerStatusContainerCannotRun = ContainerStatus{valuer.NewString("ContainerCannotRun")}
|
||||
|
||||
ContainerStatusUnknown = ContainerStatus{valuer.NewString("Unknown")}
|
||||
|
||||
// ContainerStatusNoData is the record default: no status data / metrics disabled.
|
||||
ContainerStatusNoData = ContainerStatus{valuer.NewString("no_data")}
|
||||
)
|
||||
|
||||
func (ContainerStatus) Enum() []any {
|
||||
return []any{
|
||||
ContainerStatusRunning,
|
||||
ContainerStatusWaiting,
|
||||
ContainerStatusTerminated,
|
||||
ContainerStatusCrashLoopBackOff,
|
||||
ContainerStatusImagePullBackOff,
|
||||
ContainerStatusErrImagePull,
|
||||
ContainerStatusCreateContainerConfigError,
|
||||
ContainerStatusContainerCreating,
|
||||
ContainerStatusOOMKilled,
|
||||
ContainerStatusCompleted,
|
||||
ContainerStatusError,
|
||||
ContainerStatusContainerCannotRun,
|
||||
ContainerStatusUnknown,
|
||||
ContainerStatusNoData,
|
||||
}
|
||||
}
|
||||
|
||||
// ContainerReady is the latest readiness of a container (k8s.container.ready).
|
||||
type ContainerReady struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ContainerReadyReady = ContainerReady{valuer.NewString("ready")}
|
||||
ContainerReadyNotReady = ContainerReady{valuer.NewString("not_ready")}
|
||||
// ContainerReadyNoData is the record default: no readiness data.
|
||||
ContainerReadyNoData = ContainerReady{valuer.NewString("no_data")}
|
||||
)
|
||||
|
||||
func (ContainerReady) Enum() []any {
|
||||
return []any{
|
||||
ContainerReadyReady,
|
||||
ContainerReadyNotReady,
|
||||
ContainerReadyNoData,
|
||||
}
|
||||
}
|
||||
|
||||
const ContainerNameAttrKey = "k8s.container.name"
|
||||
|
||||
const (
|
||||
ContainersOrderByCPU = "cpu"
|
||||
ContainersOrderByCPURequest = "cpu_request"
|
||||
ContainersOrderByCPULimit = "cpu_limit"
|
||||
ContainersOrderByMemory = "memory"
|
||||
ContainersOrderByMemoryRequest = "memory_request"
|
||||
ContainersOrderByMemoryLimit = "memory_limit"
|
||||
)
|
||||
|
||||
var ContainersValidOrderByKeys = []string{
|
||||
ContainersOrderByCPU,
|
||||
ContainersOrderByCPURequest,
|
||||
ContainersOrderByCPULimit,
|
||||
ContainersOrderByMemory,
|
||||
ContainersOrderByMemoryRequest,
|
||||
ContainersOrderByMemoryLimit,
|
||||
ContainerNameAttrKey,
|
||||
}
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
const (
|
||||
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
|
||||
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
|
||||
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
|
||||
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -84,11 +83,11 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
|
||||
return &LLMPricingRuleProcessorConfig{
|
||||
Attrs: LLMPricingRuleProcessorAttrs{
|
||||
Model: telemetrytypes.GenAIRequestModel,
|
||||
In: telemetrytypes.GenAIUsageInputTokens,
|
||||
Out: telemetrytypes.GenAIUsageOutputTokens,
|
||||
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
|
||||
Model: GenAIRequestModel,
|
||||
In: GenAIUsageInputTokens,
|
||||
Out: GenAIUsageOutputTokens,
|
||||
CacheRead: GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: GenAIUsageCacheCreationInputTokens,
|
||||
},
|
||||
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
|
||||
Rules: pricingRules,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
|
||||
// shared by the AI query builder and the LLM pricing pipeline.
|
||||
const (
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIToolName = "gen_ai.tool.name"
|
||||
GenAIAgentName = "gen_ai.agent.name"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
)
|
||||
|
||||
// GenAIFieldDefinitions are the gen_ai semantic-convention span attributes the AI
|
||||
// query builder relies on. They are surfaced by the metadata store for trace
|
||||
// queries regardless of whether they have been ingested yet, so the AI gate/columns
|
||||
// resolve on a fresh install (mirrors intrinsic metric keys). String keys are the
|
||||
// gate; the usage keys are numeric.
|
||||
var GenAIFieldDefinitions = map[string]TelemetryFieldKey{
|
||||
GenAIRequestModel: {Name: GenAIRequestModel, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIToolName: {Name: GenAIToolName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIAgentName: {Name: GenAIAgentName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIProviderName: {Name: GenAIProviderName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
|
||||
GenAIUsageInputTokens: {Name: GenAIUsageInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageOutputTokens: {Name: GenAIUsageOutputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheReadInputTokens: {Name: GenAIUsageCacheReadInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheCreationInputTokens: {Name: GenAIUsageCacheCreationInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type Source struct {
|
||||
var (
|
||||
SourceAudit = Source{valuer.NewString("audit")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAI = Source{valuer.NewString("ai")}
|
||||
SourceUnspecified = Source{valuer.NewString("")}
|
||||
)
|
||||
|
||||
@@ -18,6 +17,5 @@ var (
|
||||
func (Source) Enum() []any {
|
||||
return []any{
|
||||
SourceMeter,
|
||||
SourceAI,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user