mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
4 Commits
feat/dashb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51036d6cc4 | ||
|
|
f7bfd0eba6 | ||
|
|
da250bfd3e | ||
|
|
67007e2902 |
@@ -4189,6 +4189,7 @@ components:
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
- kube_containers
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
@@ -4294,6 +4295,158 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByReady:
|
||||
properties:
|
||||
notReady:
|
||||
type: integer
|
||||
ready:
|
||||
type: integer
|
||||
required:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesContainerCountsByStatus:
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
containerCannotRun:
|
||||
type: integer
|
||||
containerCreating:
|
||||
type: integer
|
||||
crashLoopBackOff:
|
||||
type: integer
|
||||
createContainerConfigError:
|
||||
type: integer
|
||||
errImagePull:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
imagePullBackOff:
|
||||
type: integer
|
||||
oomKilled:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
terminated:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
waiting:
|
||||
type: integer
|
||||
required:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashLoopBackOff
|
||||
- imagePullBackOff
|
||||
- errImagePull
|
||||
- createContainerConfigError
|
||||
- containerCreating
|
||||
- oomKilled
|
||||
- completed
|
||||
- error
|
||||
- containerCannotRun
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesContainerReady:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainerRecord:
|
||||
properties:
|
||||
containerCountsByReady:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
|
||||
containerCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
|
||||
containerName:
|
||||
type: string
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
cpuLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
cpuRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
memoryLimitUtilization:
|
||||
format: double
|
||||
type: number
|
||||
memoryRequestUtilization:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podUID:
|
||||
type: string
|
||||
ready:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
|
||||
restarts:
|
||||
format: int64
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
|
||||
required:
|
||||
- podUID
|
||||
- containerName
|
||||
- status
|
||||
- containerCountsByStatus
|
||||
- ready
|
||||
- containerCountsByReady
|
||||
- restarts
|
||||
- cpu
|
||||
- cpuRequestUtilization
|
||||
- cpuLimitUtilization
|
||||
- memory
|
||||
- memoryRequestUtilization
|
||||
- memoryLimitUtilization
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesContainerStatus:
|
||||
enum:
|
||||
- running
|
||||
- waiting
|
||||
- terminated
|
||||
- crashloopbackoff
|
||||
- imagepullbackoff
|
||||
- errimagepull
|
||||
- createcontainerconfigerror
|
||||
- containercreating
|
||||
- oomkilled
|
||||
- completed
|
||||
- error
|
||||
- containercannotrun
|
||||
- unknown
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesContainers:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
properties:
|
||||
currentNodes:
|
||||
@@ -4968,6 +5121,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableContainers:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableDaemonSets:
|
||||
properties:
|
||||
end:
|
||||
@@ -16099,6 +16278,86 @@ paths:
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/kube_containers:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
|
||||
metrics: CPU usage (cores), CPU request/limit utilization, memory working
|
||||
set, and memory request/limit utilization. Each container also reports health
|
||||
signals from the k8s_cluster receiver: status (kubectl-style display status
|
||||
derived from k8s.container.status.state + k8s.container.status.reason), restarts
|
||||
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
|
||||
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
|
||||
stable across container restarts. Each container includes metadata attributes
|
||||
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
|
||||
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
|
||||
as deployment/statefulset/daemonset/job). The response type is ''list'' for
|
||||
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
|
||||
with its current status and ready state) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates containers in the group with per-status counts under
|
||||
containerCountsByStatus, per-readiness counts under containerCountsByReady,
|
||||
and restarts as the group sum). Status requires the optional k8s.container.status.state
|
||||
and k8s.container.status.reason metrics; when either is missing, status is
|
||||
omitted and a warning is returned while restarts and ready are still computed.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by any
|
||||
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
|
||||
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
|
||||
and restarts return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListContainers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -17909,6 +18168,16 @@ paths:
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: query
|
||||
name: startTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: endTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2Params,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
> = ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
{
|
||||
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
|
||||
{ id, key },
|
||||
params,
|
||||
),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
@@ -11366,6 +11570,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
endTime?: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from '../useGetResolvedPublicDashboard';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardDataV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
|
||||
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
|
||||
// error code; anything else must NOT trigger the v1 fallback.
|
||||
const schemaMismatchError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 501,
|
||||
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
|
||||
},
|
||||
};
|
||||
|
||||
describe('useGetResolvedPublicDashboard', () => {
|
||||
beforeEach(() => {
|
||||
mockV2.mockReset();
|
||||
mockV1.mockReset();
|
||||
});
|
||||
|
||||
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
|
||||
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
|
||||
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
|
||||
mockV2.mockRejectedValue(schemaMismatchError);
|
||||
mockV1.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { dashboard: { data: { title: 'v1' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
|
||||
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
|
||||
});
|
||||
|
||||
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
|
||||
mockV2.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { code: 'internal' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without an id', () => {
|
||||
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
|
||||
expect(mockV2).not.toHaveBeenCalled();
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
export enum PublicDashboardSchema {
|
||||
V1 = 'v1',
|
||||
V2 = 'v2',
|
||||
}
|
||||
|
||||
export type ResolvedPublicDashboard =
|
||||
| {
|
||||
schema: PublicDashboardSchema.V2;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
|
||||
|
||||
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
|
||||
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
|
||||
|
||||
function isV2SchemaMismatch(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const { response } = error as AxiosError<ErrorV2Resp>;
|
||||
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
|
||||
}
|
||||
|
||||
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
|
||||
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
|
||||
async function resolvePublicDashboard(
|
||||
id: string,
|
||||
): Promise<ResolvedPublicDashboard> {
|
||||
try {
|
||||
const v2 = await getPublicDashboardDataV2({ id });
|
||||
return { schema: PublicDashboardSchema.V2, data: v2.data };
|
||||
} catch (error) {
|
||||
if (!isV2SchemaMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
const v1 = await getPublicDashboardDataAPI({ id });
|
||||
return { schema: PublicDashboardSchema.V1, data: v1.data };
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetResolvedPublicDashboard = (
|
||||
id: string,
|
||||
): UseQueryResult<ResolvedPublicDashboard, Error> =>
|
||||
useQuery<ResolvedPublicDashboard, Error>({
|
||||
queryFn: () => resolvePublicDashboard(id),
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -49,6 +49,23 @@ describe('ContextLinksSection utils', () => {
|
||||
{ key: 'q', value: '{{x}}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats undecodable percent sequences as literal text instead of throwing', () => {
|
||||
expect(getUrlParams('/logs?search=95%')).toStrictEqual([
|
||||
{ key: 'search', value: '95%' },
|
||||
]);
|
||||
expect(getUrlParams('/logs?95%=value')).toStrictEqual([
|
||||
{ key: '95%', value: 'value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes a valid escape once even when the result has a stray percent', () => {
|
||||
// %2525 double-decodes to '%'; 95%25 decodes once to '95%' and stops there
|
||||
expect(getUrlParams('/logs?a=95%25&b=%2525')).toStrictEqual([
|
||||
{ key: 'a', value: '95%' },
|
||||
{ key: 'b', value: '%' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUrlWithParams', () => {
|
||||
|
||||
@@ -29,18 +29,22 @@ export function insertVariableAtCursor(
|
||||
);
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire; decode a second time only when it changes
|
||||
// the string, so already-single-encoded values are left intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
const decoded = decodeURIComponent(value);
|
||||
// Users can type raw `%` into the URL field (e.g. `?q=95%`), which is not valid
|
||||
// percent-encoding — treat undecodable input as literal text instead of throwing.
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return decoded;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire, so decode twice; a second decode is a
|
||||
// no-op once nothing is left to unescape, leaving single-encoded values intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
return safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
export function getUrlParams(url: string): UrlParam[] {
|
||||
const [, queryString] = url.split('?');
|
||||
@@ -53,7 +57,7 @@ export function getUrlParams(url: string): UrlParam[] {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: decodeURIComponent(key),
|
||||
key: safeDecodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ColumnUnitsProps {
|
||||
columns: TableColumnOption[];
|
||||
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
|
||||
value: Record<string, string>;
|
||||
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
|
||||
metricUnit?: string;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -53,6 +56,7 @@ function ColumnUnits({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
initialValue={metricUnit}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function FormattingSection({
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
@@ -100,4 +100,33 @@ describe('FormattingSection', () => {
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when a column unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but the column is set to bytes.
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no warning when the column unit matches the metric unit', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 's' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,7 @@
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
:global(.ant-tabs-nav) {
|
||||
// Pin the query-type tabs + Run Query button to the top of the
|
||||
// `.container` scroll area while the query body scrolls underneath.
|
||||
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 1100;
|
||||
padding-top: 12px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -40,6 +35,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in pin to the top of the scroll area; the View modal opts out (shares a scroll area with its own header).
|
||||
.stickyNav :global(.ant-tabs-nav) {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 1100;
|
||||
}
|
||||
.queryTypeTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
@@ -45,6 +46,8 @@ interface PanelEditorQueryBuilderProps {
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
/** Pin the tabs + Run Query row to the top of the scroll area. Off in the View modal, which shares a scroll area with its own header. */
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +62,7 @@ function PanelEditorQueryBuilder({
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
footer,
|
||||
stickyHeader = true,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
@@ -156,7 +160,9 @@ function PanelEditorQueryBuilder({
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={styles.tabsContainer}
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
@@ -19,7 +20,7 @@ const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -196,7 +202,10 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -299,6 +308,32 @@ describe('PanelEditorContainer composition', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks an existing panel to be revealed when the editor is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1');
|
||||
});
|
||||
|
||||
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
describe('useMetricYAxisUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('seeds the unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the metric has no unit', () => {
|
||||
mockMetricUnit(undefined);
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { unit: string | undefined }) =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: true,
|
||||
unit: props.unit,
|
||||
onSelectUnit,
|
||||
}),
|
||||
{ initialProps: { unit: undefined as string | undefined } },
|
||||
);
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
|
||||
|
||||
// The metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ unit: 'bytes' });
|
||||
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: false,
|
||||
unit: undefined,
|
||||
onSelectUnit: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -247,11 +247,13 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
const withSource = (id: string, ...dataSources: string[]): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
builder: {
|
||||
queryData: dataSources.map((dataSource) => ({ dataSource })),
|
||||
},
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
@@ -286,6 +288,58 @@ describe('usePanelEditorQuerySync', () => {
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not commit when a query is added (the fresh query must not auto-run)', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'metrics') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics', 'metrics');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits when a query is removed', () => {
|
||||
const state = builderState({
|
||||
currentQuery: withSource('a', 'metrics', 'logs'),
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a datasource switch on a query added after mount', () => {
|
||||
const state = builderState({ currentQuery: withSource('a', 'metrics') });
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
state.currentQuery = withSource('b', 'metrics', 'metrics');
|
||||
rerender();
|
||||
state.currentQuery = withSource('c', 'metrics', 'logs');
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -44,7 +46,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -53,6 +55,25 @@ describe('usePanelEditorSave', () => {
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
// Editing resolves with the panel's own id.
|
||||
expect(savedPanelId).toBe('panel-9');
|
||||
});
|
||||
|
||||
it('mints and resolves with a fresh id when creating a new panel', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(savedPanelId).toBe('minted-panel-id');
|
||||
expect(mockPatchAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import type { TableColumnOption } from '../useTableColumns';
|
||||
import { useSeedMetricUnit } from '../useSeedMetricUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
|
||||
?.unit;
|
||||
}
|
||||
|
||||
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
|
||||
.formatting?.columnUnits;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnOption[] = [
|
||||
{ key: 'A', label: 'A' },
|
||||
{ key: 'B', label: 'B' },
|
||||
];
|
||||
|
||||
const NO_COLUMNS: TableColumnOption[] = [];
|
||||
|
||||
describe('useSeedMetricUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('panel-wide unit (controls.unit)', () => {
|
||||
it('seeds formatting.unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec({ unit: 'bytes' }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: props.spec,
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { spec: makeSpec() } },
|
||||
);
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
|
||||
// Metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ spec: makeSpec({ unit: 'bytes' }) });
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-column units (controls.columnUnits)', () => {
|
||||
it('seeds every value column with the metric unit once columns resolve', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { columns: TableColumnOption[] }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: props.columns,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { columns: NO_COLUMNS } },
|
||||
);
|
||||
// Waits for results to resolve the columns.
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ columns: COLUMNS });
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'bytes',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes formatting.unit for a Table', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true, decimals: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only fills columns without a unit yet, keeping the user-set one', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'ms',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when every column already has a unit', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds once and does not re-run after the metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { metric: string }) => {
|
||||
mockMetricUnit(props.metric);
|
||||
return useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
});
|
||||
},
|
||||
{ initialProps: { metric: 'bytes' } },
|
||||
);
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ metric: 'ms' });
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: undefined,
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
interface UseMetricYAxisUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
|
||||
isNewPanel: boolean;
|
||||
unit: string | undefined;
|
||||
onSelectUnit: (unit: string) => void;
|
||||
}
|
||||
|
||||
interface UseMetricYAxisUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
|
||||
* from it (V1 parity); returns the unit for the selector's mismatch warning.
|
||||
*/
|
||||
export function useMetricYAxisUnit({
|
||||
isNewPanel,
|
||||
unit,
|
||||
onSelectUnit,
|
||||
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPanel && metricUnit && metricUnit !== unit) {
|
||||
onSelectUnit(metricUnit);
|
||||
}
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel, metricUnit]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -111,17 +111,27 @@ export function usePanelEditorQuerySync({
|
||||
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[currentQuery.builder],
|
||||
);
|
||||
const dataSourceSignature = dataSources.join(',');
|
||||
const prevDataSourcesRef = useRef(dataSources);
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const prev = prevDataSourcesRef.current;
|
||||
prevDataSourcesRef.current = dataSources;
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
// An added query is still empty — don't auto-run it; it commits on Run Query.
|
||||
const isQueryAdded =
|
||||
dataSources.length > prev.length &&
|
||||
prev.every((source, index) => source === dataSources[index]);
|
||||
if (isQueryAdded) {
|
||||
return;
|
||||
}
|
||||
commitRef.current(queryRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
@@ -23,7 +23,8 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -44,17 +45,20 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
// The id a new panel is persisted under (surfaced so the caller can reveal it).
|
||||
let savedPanelId = panelId;
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -69,6 +73,7 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import type {
|
||||
SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
|
||||
import type { TableColumnOption } from './useTableColumns';
|
||||
|
||||
type FormattingControls = SectionControls[SectionKind.Formatting];
|
||||
|
||||
interface UseSeedMetricUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
|
||||
isNewPanel: boolean;
|
||||
/**
|
||||
* The current kind's Formatting controls — the single source of truth for which
|
||||
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
|
||||
* A kind with neither (Histogram/List) seeds nothing.
|
||||
*/
|
||||
formattingControls: FormattingControls | undefined;
|
||||
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
|
||||
columns: TableColumnOption[];
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UseSeedMetricUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
|
||||
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
|
||||
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
|
||||
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
|
||||
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
|
||||
* mismatch warning.
|
||||
*/
|
||||
export function useSeedMetricUnit({
|
||||
isNewPanel,
|
||||
formattingControls,
|
||||
columns,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
const seedsUnit = isNewPanel && !!formattingControls?.unit;
|
||||
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
|
||||
|
||||
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
|
||||
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
|
||||
useEffect(() => {
|
||||
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsUnit, metricUnit]);
|
||||
|
||||
// Per-column units (Table): seed once, only for columns without a unit yet, so it
|
||||
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
|
||||
const seededColumnsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!seedsColumnUnits ||
|
||||
seededColumnsRef.current ||
|
||||
!metricUnit ||
|
||||
columns.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
|
||||
const unset = columns.filter(
|
||||
(column) => columnUnits[column.key] === undefined,
|
||||
);
|
||||
seededColumnsRef.current = true;
|
||||
if (unset.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nextColumnUnits = { ...columnUnits };
|
||||
unset.forEach((column) => {
|
||||
nextColumnUnits[column.key] = metricUnit;
|
||||
});
|
||||
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
|
||||
// Seed once columns first resolve with a unit; not on later spec edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsColumnUnits, metricUnit, columns]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesPanelFormattingDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
@@ -124,32 +128,20 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
}
|
||||
).formatting?.unit;
|
||||
const seedFormattingUnit = useCallback(
|
||||
(unit: string): void => {
|
||||
const pluginSpec = spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
};
|
||||
setSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
},
|
||||
[spec, setSpec],
|
||||
);
|
||||
const { metricUnit } = useMetricYAxisUnit({
|
||||
isNewPanel: isNew,
|
||||
unit: formattingUnit,
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
@@ -188,6 +180,17 @@ function PanelEditorContainer({
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
@@ -203,19 +206,33 @@ function PanelEditorContainer({
|
||||
query: currentQuery,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -227,7 +244,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../formattingSpec';
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('formattingSpec', () => {
|
||||
it('reads the formatting slice (undefined when absent)', () => {
|
||||
expect(readFormatting(makeSpec())).toBeUndefined();
|
||||
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the patch into the formatting slice, preserving other fields', () => {
|
||||
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
|
||||
unit: 'bytes',
|
||||
});
|
||||
expect(readFormatting(next)).toStrictEqual({
|
||||
decimalPrecision: '2',
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input spec', () => {
|
||||
const spec = makeSpec({ unit: 'ms' });
|
||||
writeFormatting(spec, { unit: 'bytes' });
|
||||
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
|
||||
// to the shared `formatting` slice via a single localized cast at the boundary, so
|
||||
// callers read/write it without repeating the spread.
|
||||
|
||||
export function readFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): PanelFormattingSlice | undefined {
|
||||
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
|
||||
}
|
||||
|
||||
/** Merges a partial formatting patch into the panel's `formatting` slice. */
|
||||
export function writeFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
patch: Partial<PanelFormattingSlice>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -28,14 +28,36 @@ const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
/** A panel spec carrying the plugin.spec a seed reads; the rest of the shape is irrelevant. */
|
||||
function oldSpecWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
function oldSpecWith(
|
||||
pluginSpec: unknown,
|
||||
queries: unknown[] = [],
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function builderQueryNamed(name: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: { name, aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compositeQueryWith(envelopes: unknown[]): unknown {
|
||||
return {
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
@@ -47,16 +69,15 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
|
||||
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
]);
|
||||
@@ -112,6 +133,108 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old timePreference / stacking / fillSpans the target declares', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: {
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
|
||||
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: { stackedBarChart: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
customColors: { 'series-a': '#F1575F' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('axes seed (carry, gated by controls)', () => {
|
||||
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
softMin: 0,
|
||||
softMax: 100,
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries only the fields the target controls declare', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
expect(
|
||||
buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
@@ -137,6 +260,30 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old values over the defaults, gated by the declared controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
showPoints: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
showPoints: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting seed (carry, gated by controls)', () => {
|
||||
@@ -154,7 +301,7 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
|
||||
it('drops the panel-wide unit when no column keys are derivable (TimeSeries → Table, no queries)', () => {
|
||||
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -171,6 +318,56 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fans the panel-wide unit out to every value column (TimeSeries → Table)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Formatting,
|
||||
controls: { decimals: true, columnUnits: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith(
|
||||
{ formatting: { unit: 'ms', decimalPrecision: 2 } },
|
||||
[
|
||||
compositeQueryWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 2,
|
||||
columnUnits: {
|
||||
'A.count()': 'ms',
|
||||
'A.sum(bytes)': 'ms',
|
||||
B: 'ms',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('never seeds a panel-wide unit from per-column units (Table → TimeSeries)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith(
|
||||
{ formatting: { columnUnits: { A: 'ms', B: 'ns' }, decimalPrecision: 2 } },
|
||||
[builderQueryNamed('A')],
|
||||
);
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a decimalPrecision of 0 (falsy but defined) and omits missing fields', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
@@ -225,12 +422,14 @@ describe('buildPluginSpec', () => {
|
||||
function switchThresholds(
|
||||
variant: ThresholdVariant | undefined,
|
||||
thresholds: unknown[],
|
||||
queries: unknown[] = [],
|
||||
): unknown {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Thresholds, controls: { variant } },
|
||||
];
|
||||
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
|
||||
.thresholds;
|
||||
return buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ thresholds }, queries),
|
||||
}).thresholds;
|
||||
}
|
||||
|
||||
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
|
||||
@@ -258,27 +457,55 @@ describe('buildPluginSpec', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves existing operator/format when remapping comparison → table', () => {
|
||||
it('preserves operator/format and keys onto the first query column when remapping comparison → table', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]),
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: '',
|
||||
columnName: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an existing columnName instead of the derived default', () => {
|
||||
expect(
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[{ value: 80, color: '#F1575F', columnName: 'p99' }],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: 'p99',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops table thresholds when no column can be derived (empty columnName fails the save)', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.TABLE, [{ value: 80, color: '#F1575F' }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops table-only operator/format/columnName when remapping table → label', () => {
|
||||
expect(
|
||||
switchThresholds(ThresholdVariant.LABEL, [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getTableColumnKeys } from '../getTableColumnKeys';
|
||||
|
||||
function bareBuilderQuery(spec: unknown): DashboardtypesQueryDTO {
|
||||
return {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
function compositeQuery(envelopes: unknown[]): DashboardtypesQueryDTO {
|
||||
return {
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
} as unknown as DashboardtypesQueryDTO;
|
||||
}
|
||||
|
||||
describe('getTableColumnKeys', () => {
|
||||
it('keys single-aggregation queries by name and multi-aggregation ones per expression (matches getColId)', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()' }, { expression: 'sum(bytes)' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual([
|
||||
'A.count()',
|
||||
'A.sum(bytes)',
|
||||
'B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips disabled queries', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual(['B']);
|
||||
});
|
||||
|
||||
it('keys non-builder envelopes by name but skips clickhouse_sql (columns keyed by unknown SQL alias)', () => {
|
||||
const queries = [
|
||||
compositeQuery([
|
||||
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A * 2' } },
|
||||
{ type: 'clickhouse_sql', spec: { name: 'CH1', query: 'SELECT 1' } },
|
||||
]),
|
||||
];
|
||||
expect(getTableColumnKeys(queries)).toStrictEqual(['F1']);
|
||||
});
|
||||
|
||||
it('reads a bare builder-query envelope', () => {
|
||||
expect(getTableColumnKeys([bareBuilderQuery({ name: 'A' })])).toStrictEqual([
|
||||
'A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no keys when there is no enabled named query', () => {
|
||||
expect(getTableColumnKeys([])).toStrictEqual([]);
|
||||
expect(
|
||||
getTableColumnKeys([
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', disabled: true } },
|
||||
]),
|
||||
]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
type ControlledSectionKind,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -20,13 +21,18 @@ import {
|
||||
type SectionSpecMap,
|
||||
ThresholdVariant,
|
||||
} from '../types/sections';
|
||||
import { getTableColumnKeys } from './getTableColumnKeys';
|
||||
|
||||
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
|
||||
export interface SeededPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
formatting?: Pick<
|
||||
PanelFormattingSlice,
|
||||
'unit' | 'decimalPrecision' | 'columnUnits'
|
||||
>;
|
||||
selectFields?: SectionSpecMap[SectionKind.Columns];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
@@ -37,6 +43,11 @@ export interface SeedContext {
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/** `SeedContext` plus `oldSpec.plugin.spec` (typed `unknown`) resolved once, so seeds read it without re-casting. */
|
||||
interface ResolvedSeedContext extends SeedContext {
|
||||
oldPluginSpec?: SeededPluginSpec;
|
||||
}
|
||||
|
||||
interface AnyThresholdFields {
|
||||
color: string;
|
||||
value: number;
|
||||
@@ -51,6 +62,7 @@ interface AnyThresholdFields {
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
defaultColumnName?: string,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
@@ -69,7 +81,7 @@ function toThresholdVariant(
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName ?? '',
|
||||
columnName: source.columnName || defaultColumnName || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -79,97 +91,166 @@ function toThresholdVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* How one section derives its plugin-spec slice on create/switch — the single place a section
|
||||
* declares this. Sections absent from `SECTION_SEEDS` seed nothing.
|
||||
* How each section derives its plugin-spec slice on create/switch — the single place a section
|
||||
* declares this. Sections absent from `SECTION_SEEDS` seed nothing. Mapped over `SectionKind` so
|
||||
* every `seed` receives its own kind's `controls` (atomic kinds get `undefined`), no per-seed cast.
|
||||
* A `seed` returns an empty object/array (never `undefined`) when it has nothing to carry;
|
||||
* `buildPluginSpec` drops the empties so the omit decision lives in one place.
|
||||
*/
|
||||
interface SectionSeed {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (controls: unknown, ctx: SeedContext) => unknown;
|
||||
type SectionSeeds = {
|
||||
[K in SectionKind]?: {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (
|
||||
controls: K extends ControlledSectionKind ? SectionControls[K] : undefined,
|
||||
ctx: ResolvedSeedContext,
|
||||
) => object;
|
||||
};
|
||||
};
|
||||
|
||||
function isEmptySlice(value: object): boolean {
|
||||
return Array.isArray(value)
|
||||
? value.length === 0
|
||||
: Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
seed: (
|
||||
controls,
|
||||
{ oldPluginSpec },
|
||||
): SectionSpecMap[SectionKind.Visualization] => {
|
||||
const old = oldPluginSpec?.visualization;
|
||||
return {
|
||||
...(controls.timePreference && {
|
||||
timePreference:
|
||||
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
|
||||
}),
|
||||
...(controls.stacking &&
|
||||
old?.stackedBarChart !== undefined && {
|
||||
stackedBarChart: old.stackedBarChart,
|
||||
}),
|
||||
...(controls.fillSpans &&
|
||||
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
specKey: 'axes',
|
||||
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Axes] => {
|
||||
const old = oldPluginSpec?.axes;
|
||||
if (!old) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(controls.minMax &&
|
||||
typeof old.softMin === 'number' && { softMin: old.softMin }),
|
||||
...(controls.minMax &&
|
||||
typeof old.softMax === 'number' && { softMax: old.softMax }),
|
||||
...(controls.logScale &&
|
||||
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Legend] => {
|
||||
const old = oldPluginSpec?.legend;
|
||||
// customColors is keyed by series label, which the new kind may not reproduce.
|
||||
return controls.position
|
||||
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
|
||||
: {};
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
seed: (
|
||||
controls,
|
||||
{ oldPluginSpec },
|
||||
): SectionSpecMap[SectionKind.ChartAppearance] => {
|
||||
// One guard on the optional old slice, then read fields with carried-or-default values.
|
||||
const {
|
||||
lineStyle = DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode = DashboardtypesFillModeDTO.none,
|
||||
showPoints,
|
||||
spanGaps,
|
||||
} = oldPluginSpec?.chartAppearance ?? {};
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
if (controls.lineStyle) {
|
||||
appearance.lineStyle = lineStyle;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
if (controls.lineInterpolation) {
|
||||
appearance.lineInterpolation = lineInterpolation;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
if (controls.fillMode) {
|
||||
appearance.fillMode = fillMode;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
if (controls.showPoints && showPoints !== undefined) {
|
||||
appearance.showPoints = showPoints;
|
||||
}
|
||||
if (controls.spanGaps && spanGaps !== undefined) {
|
||||
appearance.spanGaps = spanGaps;
|
||||
}
|
||||
return appearance;
|
||||
},
|
||||
},
|
||||
[SectionKind.Formatting]: {
|
||||
specKey: 'formatting',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Formatting];
|
||||
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
?.formatting;
|
||||
{ oldSpec, oldPluginSpec },
|
||||
): NonNullable<SeededPluginSpec['formatting']> => {
|
||||
const old = oldPluginSpec?.formatting;
|
||||
// Carry a field only when the target kind declares it (e.g. Table has no `unit`),
|
||||
// else the save API rejects the spec.
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(c.decimals &&
|
||||
const carried: NonNullable<SeededPluginSpec['formatting']> = {
|
||||
...(controls.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(controls.decimals &&
|
||||
old?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: old.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
return Object.keys(carried).length > 0 ? carried : undefined;
|
||||
// A panel-wide unit fans out to every value column when the target keys units
|
||||
// per column (→ Table). One-way: `columnUnits` never seed a panel-wide `unit`.
|
||||
const unit = old?.unit;
|
||||
if (controls.columnUnits && unit) {
|
||||
const keys = getTableColumnKeys(oldSpec?.queries ?? []);
|
||||
if (keys.length > 0) {
|
||||
carried.columnUnits = Object.fromEntries(keys.map((key) => [key, unit]));
|
||||
}
|
||||
}
|
||||
return carried;
|
||||
},
|
||||
},
|
||||
[SectionKind.Columns]: {
|
||||
specKey: 'selectFields',
|
||||
seed: (
|
||||
_controls,
|
||||
{ signal },
|
||||
): SectionSpecMap[SectionKind.Columns] | undefined => {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
return columns.length > 0 ? columns : undefined;
|
||||
},
|
||||
seed: (_controls, { signal }): SectionSpecMap[SectionKind.Columns] =>
|
||||
signal ? defaultColumnsForSignal(signal) : [],
|
||||
},
|
||||
[SectionKind.Thresholds]: {
|
||||
specKey: 'thresholds',
|
||||
seed: (controls, { oldSpec }): AnyThreshold[] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Thresholds];
|
||||
const variant = c.variant ?? ThresholdVariant.LABEL;
|
||||
const old = (oldSpec?.plugin.spec as { thresholds?: AnyThreshold[] | null })
|
||||
?.thresholds;
|
||||
seed: (controls, { oldSpec, oldPluginSpec }): AnyThreshold[] => {
|
||||
const variant = controls.variant ?? ThresholdVariant.LABEL;
|
||||
const old = oldPluginSpec?.thresholds;
|
||||
if (!old || old.length === 0) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
return old.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, variant),
|
||||
// The save API rejects an empty table-threshold columnName.
|
||||
const defaultColumnName =
|
||||
variant === ThresholdVariant.TABLE
|
||||
? getTableColumnKeys(oldSpec?.queries ?? [])[0]
|
||||
: undefined;
|
||||
const mapped = old.map((threshold) =>
|
||||
toThresholdVariant(
|
||||
threshold as AnyThresholdFields,
|
||||
variant,
|
||||
defaultColumnName,
|
||||
),
|
||||
);
|
||||
return variant === ThresholdVariant.TABLE
|
||||
? mapped.filter((t) => (t as { columnName?: string }).columnName)
|
||||
: mapped;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -184,14 +265,23 @@ export function buildPluginSpec(
|
||||
): SeededPluginSpec {
|
||||
const spec: SeededPluginSpec = {};
|
||||
|
||||
// One localized cast for all seeds: `plugin.spec` is typed `unknown`.
|
||||
const resolved: ResolvedSeedContext = {
|
||||
...ctx,
|
||||
oldPluginSpec: ctx.oldSpec?.plugin.spec as SeededPluginSpec | undefined,
|
||||
};
|
||||
|
||||
sections.forEach((section) => {
|
||||
const entry = SECTION_SEEDS[section.kind];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const controls = 'controls' in section ? section.controls : undefined;
|
||||
const value = entry.seed(controls, ctx);
|
||||
if (value !== undefined) {
|
||||
// The lookup can't prove `section.controls` matches `entry`'s key, so `entry.seed`
|
||||
// is a union of differently-typed fns; one boundary cast, in place of a per-seed one.
|
||||
const seed = entry.seed as (c: unknown, ctx: ResolvedSeedContext) => object;
|
||||
const value = seed(controls, resolved);
|
||||
if (!isEmptySlice(value)) {
|
||||
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
|
||||
(spec as Record<string, unknown>)[entry.specKey] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolveContextLinkUrl } from '../resolveContextLinkUrl';
|
||||
|
||||
describe('resolveContextLinkUrl', () => {
|
||||
const vars = { timestamp_start: '1720512000000', service_name: 'frontend' };
|
||||
|
||||
it('resolves a variable in the base URL path', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl('https://google.com/{{timestamp_start}}', vars),
|
||||
).toBe('https://google.com/1720512000000');
|
||||
});
|
||||
|
||||
it('resolves variables in query params', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl('https://x.com/d?service={{service_name}}', vars),
|
||||
).toBe('https://x.com/d?service=frontend');
|
||||
});
|
||||
|
||||
// Regression: a literal `%` must not abort resolution of the base-URL variable.
|
||||
it('keeps resolving when a param value contains a literal %', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://google.com/{{timestamp_start}}?name=95%',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://google.com/1720512000000?name=95%25');
|
||||
});
|
||||
|
||||
it('resolves both a base-URL variable and a param variable together', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://x.com/{{service_name}}?ts={{timestamp_start}}',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://x.com/frontend?ts=1720512000000');
|
||||
});
|
||||
|
||||
it('leaves an unknown variable token untouched', () => {
|
||||
expect(resolveContextLinkUrl('https://x.com/{{unknown}}', vars)).toBe(
|
||||
'https://x.com/{{unknown}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the base URL unchanged when there is no query string', () => {
|
||||
expect(resolveContextLinkUrl('https://x.com/plain', vars)).toBe(
|
||||
'https://x.com/plain',
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes double-encoded param values before resolving', () => {
|
||||
expect(
|
||||
resolveContextLinkUrl(
|
||||
'https://x.com/d?ts=%257B%257Btimestamp_start%257D%257D',
|
||||
vars,
|
||||
),
|
||||
).toBe('https://x.com/d?ts=1720512000000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
// A literal `%` (e.g. `?name=95%`) isn't valid percent-encoding — fall back to the raw string.
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `{{var}}` tokens in a context-link URL. V2-local rather than the shared V1
|
||||
* `processContextLinks`, which throws on a literal `%` and drops the whole URL to its template.
|
||||
*/
|
||||
export function resolveContextLinkUrl(
|
||||
url: string,
|
||||
processedVariables: Record<string, string>,
|
||||
): string {
|
||||
const [baseUrl, queryString] = url.split('?');
|
||||
const resolvedBase = resolveTexts({ texts: [baseUrl], processedVariables })
|
||||
.fullTexts[0];
|
||||
|
||||
if (!queryString) {
|
||||
return resolvedBase;
|
||||
}
|
||||
|
||||
const resolvedQuery = Array.from(new URLSearchParams(queryString).entries())
|
||||
.map(([key, value]) => {
|
||||
// Decode twice for double-encoded values; safe so a bad `%` doesn't abort.
|
||||
const decoded = safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
const resolvedValue = resolveTexts({
|
||||
texts: [decoded],
|
||||
processedVariables,
|
||||
}).fullTexts[0];
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(resolvedValue)}`;
|
||||
})
|
||||
.join('&');
|
||||
|
||||
return `${resolvedBase}?${resolvedQuery}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
|
||||
|
||||
/** A panel context link with its label and URL templates resolved, ready to render. */
|
||||
export interface ResolvedDrilldownLink {
|
||||
@@ -10,10 +11,8 @@ export interface ResolvedDrilldownLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
|
||||
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
|
||||
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
|
||||
* label/URL (no substitution).
|
||||
* Resolves a panel's context links for the drilldown menu: substitutes variables in each link's
|
||||
* label + URL, drops links without a URL, and skips substitution when `renderVariables === false`.
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardLinkDTO[] | undefined,
|
||||
@@ -24,27 +23,17 @@ export function resolvePanelContextLinks(
|
||||
return [];
|
||||
}
|
||||
|
||||
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
}));
|
||||
|
||||
const resolved = processContextLinks(adapted, processedVariables, 50);
|
||||
|
||||
return usable.map((link, index) => {
|
||||
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
|
||||
const rawLabel = link.name || link.url || '';
|
||||
const rawUrl = link.url ?? '';
|
||||
// Only an explicit `false` opts out; undefined defaults to substitution on.
|
||||
if (link.renderVariables === false) {
|
||||
return {
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
};
|
||||
return { id: String(index), label: rawLabel, url: rawUrl };
|
||||
}
|
||||
return {
|
||||
id: resolved[index].id,
|
||||
label: resolved[index].label,
|
||||
url: resolved[index].url,
|
||||
id: String(index),
|
||||
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
|
||||
url: resolveContextLinkUrl(rawUrl, processedVariables),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import {
|
||||
type AggregationView,
|
||||
getAggregationColumnKey,
|
||||
} from '../../queryV5/prepareScalarTables';
|
||||
|
||||
// Narrow view over any envelope spec: every variant carries name/disabled,
|
||||
// only builder queries have aggregations.
|
||||
interface QuerySpecView {
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
aggregations?: AggregationView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys every enabled query's value columns render under (one per aggregation on
|
||||
* multi-aggregation queries), derived from the panel's queries alone — lets a kind
|
||||
* switch key per-column config before any data exists. clickhouse_sql queries are
|
||||
* skipped: their columns are keyed by the response's SQL alias, unknowable here.
|
||||
*/
|
||||
export function getTableColumnKeys(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): string[] {
|
||||
const keys = new Set<string>();
|
||||
toQueryEnvelopes(queries).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType.clickhouse_sql
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec as QuerySpecView | undefined;
|
||||
if (!spec?.name || spec.disabled) {
|
||||
return;
|
||||
}
|
||||
const { aggregations } = spec;
|
||||
const columnCount = Math.max(aggregations?.length ?? 0, 1);
|
||||
for (let index = 0; index < columnCount; index += 1) {
|
||||
keys.add(getAggregationColumnKey(spec.name, aggregations, index));
|
||||
}
|
||||
});
|
||||
return [...keys];
|
||||
}
|
||||
@@ -131,6 +131,7 @@ function ViewPanelModalContent({
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
|
||||
@@ -68,7 +68,7 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState',
|
||||
() => ({
|
||||
ALL_SELECTED: '__ALL__',
|
||||
variablesUrlParser: { withOptions: (): unknown => ({}) },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useClonePanel } from '../useClonePanel';
|
||||
|
||||
@@ -47,6 +48,7 @@ describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1' });
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
@@ -132,6 +134,21 @@ describe('useClonePanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records the cloned panel as the scroll target when the toast auto-closes', async () => {
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
// The reveal is deferred to the toast's auto-close, not fired inline.
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
|
||||
onAutoClose: () => void;
|
||||
};
|
||||
onAutoClose();
|
||||
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('cloned-id');
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
@@ -32,6 +33,7 @@ export function useClonePanel({
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
@@ -64,6 +66,9 @@ export function useClonePanel({
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
duration: 2000,
|
||||
// Defer the reveal to the toast's auto-close so the confirmation shows first.
|
||||
onAutoClose: () => setScrollTargetId(newPanelId),
|
||||
});
|
||||
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
@@ -74,6 +79,6 @@ export function useClonePanel({
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, patchAsync],
|
||||
[sections, dashboardId, patchAsync, setScrollTargetId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
|
||||
|
||||
interface UseDrilldownDashboardVariablesArgs {
|
||||
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { bottomRowSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Params {
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a fresh row at
|
||||
* the bottom of the target section (`bottomRowSlot`). Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,12 +52,10 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
// Land at the section bottom, not backfilled into a gap — least disruptive
|
||||
// to the arrangement the user already made in the target section.
|
||||
const { x, y } = bottomRowSlot(target.items);
|
||||
const targetItems = [...target.items, { ...moved, x, y }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
@@ -65,6 +66,9 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
@@ -77,6 +81,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
// Untitled section — just the grid, no header chrome.
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
@@ -87,6 +92,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
@@ -31,6 +32,7 @@ function SectionGridItem({
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
useScrollIntoView(panelId, containerRef);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useScrollIntoViewStore } from '../../../../store/useScrollIntoViewStore';
|
||||
import { useScrollIntoView } from '../useScrollIntoView';
|
||||
|
||||
function refWithScroll(scrollIntoView: jest.Mock): RefObject<HTMLElement> {
|
||||
return {
|
||||
current: { scrollIntoView } as unknown as HTMLElement,
|
||||
} as RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
describe('useScrollIntoView', () => {
|
||||
beforeEach(() => {
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
|
||||
it('scrolls into view and clears the request when the store targets this id', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: 'p1' });
|
||||
|
||||
renderHook(() => useScrollIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals a section id the same way (one hook for panels and sections)', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: 'sec-empty-1' });
|
||||
|
||||
renderHook(() =>
|
||||
useScrollIntoView('sec-empty-1', refWithScroll(scrollIntoView)),
|
||||
);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it('honours the caller-provided block alignment', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: 'p1' });
|
||||
|
||||
renderHook(() =>
|
||||
useScrollIntoView('p1', refWithScroll(scrollIntoView), 'center'),
|
||||
);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when the store targets a different id', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: 'other' });
|
||||
|
||||
renderHook(() => useScrollIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -11,27 +11,8 @@ import {
|
||||
reorderLayoutsOp,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
|
||||
|
||||
/**
|
||||
* Waits (via rAF) for the appended section to render, then scrolls it into view.
|
||||
* Polls because the optimistic cache write commits to the DOM a frame or two after
|
||||
* the patch call; bails after ~40 frames.
|
||||
*/
|
||||
function scrollToNewSection(prevCount: number, attempts = 40): void {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
if (sections.length > prevCount) {
|
||||
sections[sections.length - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
|
||||
}
|
||||
}
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import { getSectionStableId } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
@@ -52,6 +33,7 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
@@ -59,22 +41,24 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
const isFirstSection = !layouts || layouts.length === 0;
|
||||
const op = isFirstSection
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
// The new empty section is appended, so its layout index is the prior count;
|
||||
// key it the way `getSectionStableId` does so it reveals itself on render.
|
||||
const newIndex = isFirstSection ? 0 : layouts.length;
|
||||
setScrollTargetId(getSectionStableId([], newIndex));
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, patchAsync, showErrorModal],
|
||||
[layouts, dashboardId, patchAsync, showErrorModal, setScrollTargetId],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
|
||||
/**
|
||||
* Scrolls this panel/section into view when the store targets its id, then clears the
|
||||
* request so it fires once. Runs on mount, so it catches the element as the grid renders it.
|
||||
*/
|
||||
export function useScrollIntoView(
|
||||
id: string,
|
||||
ref: RefObject<HTMLElement>,
|
||||
block: ScrollLogicalPosition = 'start',
|
||||
): void {
|
||||
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollTargetId !== id) {
|
||||
return;
|
||||
}
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block });
|
||||
setScrollTargetId(null);
|
||||
}, [scrollTargetId, id, ref, block, setScrollTargetId]);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../useSeedVariableSelection';
|
||||
|
||||
const mockSetUrlValues = jest.fn();
|
||||
let mockUrlValues: Record<string, unknown> | null = null;
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
useQueryState: (): unknown => [mockUrlValues, mockSetUrlValues],
|
||||
}));
|
||||
|
||||
// The hook maps spec DTOs through dtoToFormModel; identity lets tests pass form
|
||||
// models directly as `spec.variables`.
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
dtoToFormModel: (dto: unknown): unknown => dto,
|
||||
}));
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
id: string,
|
||||
variables: VariableFormModel[],
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id,
|
||||
spec: { variables },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function seededValue(dashboardId: string, name: string): unknown {
|
||||
return useDashboardStore.getState().variableValues[dashboardId]?.[name];
|
||||
}
|
||||
|
||||
describe('useSeedVariableSelection', () => {
|
||||
afterEach(() => {
|
||||
mockUrlValues = null;
|
||||
mockSetUrlValues.mockClear();
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds from the URL over the stored value and the default', () => {
|
||||
mockUrlValues = { env: 'from-url' };
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: 'stored', allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'from-url',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the stored value when the URL has no entry', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: 'stored', allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'stored',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default when neither URL nor store has a value', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', defaultValue: 'default' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'default',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes the fetch context with idle states for every variable', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT' }),
|
||||
model({ name: 'service', type: 'CUSTOM', customValue: 'a,b' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.variableFetchContext?.variableTypes).toStrictEqual({
|
||||
env: 'TEXT',
|
||||
service: 'CUSTOM',
|
||||
});
|
||||
expect(state.variableFetchStates).toStrictEqual({
|
||||
env: 'idle',
|
||||
service: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes URL entries for variables that no longer exist', () => {
|
||||
mockUrlValues = { env: 'prod', removed: 'stale' };
|
||||
const dash = dashboard('d1', [model({ name: 'env', type: 'TEXT' })]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(mockSetUrlValues).toHaveBeenCalledWith({ env: 'prod' });
|
||||
});
|
||||
|
||||
it('writes nothing while the dashboard is still loading', () => {
|
||||
renderHook(() => useSeedVariableSelection(undefined));
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.variableValues).toStrictEqual({});
|
||||
expect(state.variableFetchContext).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { withVariablesSearch } from '../variablesUrlState';
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
}));
|
||||
|
||||
describe('withVariablesSearch', () => {
|
||||
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
|
||||
'{"env":"prod"}',
|
||||
)}`;
|
||||
|
||||
it('returns the base unchanged when the current search has no variables', () => {
|
||||
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
|
||||
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
|
||||
'?panelKind=signoz/TablePanel',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries only the variables param onto an empty base', () => {
|
||||
const result = withVariablesSearch('', current);
|
||||
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
|
||||
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
|
||||
});
|
||||
|
||||
it('appends the variables param to existing base params', () => {
|
||||
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
|
||||
const params = new URLSearchParams(result);
|
||||
expect(params.get('panelKind')).toBe('signoz/TablePanel');
|
||||
expect(params.get('variables')).toBe('{"env":"prod"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
deriveFetchContext,
|
||||
type VariableFetchContext,
|
||||
} from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = model.defaultValue;
|
||||
if (
|
||||
def === ALL_SELECTED ||
|
||||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
|
||||
) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
|
||||
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
function fromUrlValue(
|
||||
raw: SelectedVariableValue,
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
return { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseSeedVariableSelectionResult {
|
||||
variables: VariableFormModel[];
|
||||
fetchContext: VariableFetchContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the runtime variable engine: each value (URL → persisted store →
|
||||
* default) plus the fetch context panel queries scope their cache keys by.
|
||||
* Mounted by the variables bar and by the panel-editor page, which renders
|
||||
* without the bar — without this seed a hard refresh of the editor leaves the
|
||||
* store cold and the preview fetches with unresolved variables.
|
||||
*/
|
||||
export function useSeedVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): UseSeedVariableSelectionResult {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const variables = useMemo(
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue, variable);
|
||||
} else if (selection[variable.name]) {
|
||||
seeded[variable.name] = selection[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
|
||||
// Drop URL selections for variables that no longer exist (renamed/removed),
|
||||
// so a shared link doesn't carry stale entries a later variable could inherit.
|
||||
if (urlValues) {
|
||||
const validNames = new Set(variables.map((v) => v.name));
|
||||
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
|
||||
if (orphaned) {
|
||||
const pruned: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(urlValues).forEach(([name, value]) => {
|
||||
if (validNames.has(name)) {
|
||||
pruned[name] = value;
|
||||
}
|
||||
});
|
||||
void setUrlValues(pruned);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initVariableFetch(names, fetchContext);
|
||||
}, [dashboardId, variables, fetchContext, initVariableFetch]);
|
||||
|
||||
return { variables, fetchContext };
|
||||
}
|
||||
@@ -1,69 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryState } from 'nuqs';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
deriveFetchContext,
|
||||
doAllQueryVariablesHaveValues,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
export const variablesUrlParser = parseAsJson<
|
||||
Record<string, SelectedVariableValue>
|
||||
>((v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = model.defaultValue;
|
||||
// Explicit ALL sentinel, or a multi-select allowing ALL with no default → ALL.
|
||||
if (
|
||||
def === ALL_SELECTED ||
|
||||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
|
||||
) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(
|
||||
raw: SelectedVariableValue,
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
// Only read the `__ALL__` sentinel as "ALL" for variables that support it —
|
||||
// otherwise a legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
return { value: raw, allSelected: false };
|
||||
}
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
import { doAllQueryVariablesHaveValues } from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
@@ -78,25 +27,21 @@ interface UseVariableSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
* Runtime variable selection for the variables bar: seeds values and the fetch
|
||||
* context (via useSeedVariableSelection), runs the options fetch cycle, and
|
||||
* persists changes to both the store and the URL. Never writes to the dashboard
|
||||
* spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
const { variables, fetchContext } = useSeedVariableSelection(dashboard);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
|
||||
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
|
||||
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
|
||||
const enqueueDescendantsBatch = useDashboardStore(
|
||||
@@ -112,48 +57,11 @@ export function useVariableSelection(
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
const [, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue, variable);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
|
||||
// Drop URL selections for variables that no longer exist (renamed/removed),
|
||||
// so a shared link doesn't carry stale entries a later variable could inherit.
|
||||
if (urlValues) {
|
||||
const validNames = new Set(variables.map((v) => v.name));
|
||||
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
|
||||
if (orphaned) {
|
||||
const pruned: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(urlValues).forEach(([name, value]) => {
|
||||
if (validNames.has(name)) {
|
||||
pruned[name] = value;
|
||||
}
|
||||
});
|
||||
void setUrlValues(pruned);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
// Start a full fetch cycle on load / dependency-order / time change. A value
|
||||
// change instead goes through `enqueueDescendants`, not this effect.
|
||||
const orderKey = `${fetchContext.queryVariableOrder.join(
|
||||
@@ -163,10 +71,6 @@ export function useVariableSelection(
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initVariableFetch(names, fetchContext);
|
||||
enqueueFetchAll(
|
||||
doAllQueryVariablesHaveValues(variables, selectionRef.current),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { parseAsJson } from 'nuqs';
|
||||
|
||||
import type { SelectedVariableValue } from './selectionTypes';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
export const variablesUrlParser = parseAsJson<
|
||||
Record<string, SelectedVariableValue>
|
||||
>((v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Extends a search string with the current `?variables=` param (unchanged when
|
||||
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
|
||||
* it survives a refresh (V1 parity).
|
||||
*/
|
||||
export function withVariablesSearch(
|
||||
base: string,
|
||||
currentSearch: string,
|
||||
): string {
|
||||
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
|
||||
if (!value) {
|
||||
return base;
|
||||
}
|
||||
const params = new URLSearchParams(base);
|
||||
params.set(QueryParams.variables, value);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
bottomRowSlot,
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -143,6 +146,77 @@ describe('createPanelOps', () => {
|
||||
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps to the bottom when the last-row slot is blocked by a taller earlier-row panel', () => {
|
||||
// Regression: the last row (top-y 6) has room at x:3, but the tall right
|
||||
// panel spans y:0..12 into it. Placing at x:3,y:6 would overlap it, so the
|
||||
// panel must drop to a fresh row at the bottom (y:12) instead.
|
||||
const layouts = [
|
||||
section([
|
||||
itemAt(0, 0, 6, 6),
|
||||
itemAt(6, 0, 6, 12), // tall, reaches down into the last row
|
||||
itemAt(0, 6, 3, 6),
|
||||
]),
|
||||
];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('itemsOverlap', () => {
|
||||
it('is true only when rectangles intersect on both axes', () => {
|
||||
const a = { x: 0, y: 0, width: 6, height: 6 };
|
||||
expect(itemsOverlap(a, { x: 3, y: 3, width: 6, height: 6 })).toBe(true);
|
||||
// Touching edges do not overlap (half-open intervals).
|
||||
expect(itemsOverlap(a, { x: 6, y: 0, width: 6, height: 6 })).toBe(false);
|
||||
expect(itemsOverlap(a, { x: 0, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
// Overlaps on x only (disjoint on y) → no overlap.
|
||||
expect(itemsOverlap(a, { x: 3, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFreeSlot', () => {
|
||||
it('places the first item at the origin', () => {
|
||||
expect(findFreeSlot([], 6)).toStrictEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('fills the right of the last row when it fits and is clear', () => {
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 6)).toStrictEqual({ x: 6, y: 0 });
|
||||
});
|
||||
|
||||
it('never returns a slot that overlaps an existing item', () => {
|
||||
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
|
||||
const slot = findFreeSlot(items, 6);
|
||||
const placed = { ...slot, width: 6, height: 6 };
|
||||
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
|
||||
expect(slot).toStrictEqual({ x: 0, y: 12 });
|
||||
});
|
||||
|
||||
it('clamps a too-wide panel to the grid width', () => {
|
||||
// width 20 > 12 cols → clamped to 12, so it wraps below the first row.
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 20)).toStrictEqual({ x: 0, y: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bottomRowSlot', () => {
|
||||
it('is the origin for an empty section', () => {
|
||||
expect(bottomRowSlot([])).toStrictEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('drops a fresh left-edge row below the tallest reaching item', () => {
|
||||
// max(y + height) across items: itemAt(6,0,6,12) reaches y=12.
|
||||
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12)];
|
||||
expect(bottomRowSlot(items)).toStrictEqual({ x: 0, y: 12 });
|
||||
});
|
||||
|
||||
it('never returns a slot that overlaps an existing item', () => {
|
||||
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
|
||||
const placed = { ...bottomRowSlot(items), width: 12, height: 6 };
|
||||
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneSectionOps', () => {
|
||||
|
||||
@@ -64,4 +64,10 @@ describe('useResolvedVariables', () => {
|
||||
selectResolvedVariables('d3')(useDashboardStore.getState()),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('publishes nothing while the dashboard is still loading', () => {
|
||||
renderHook(() => useResolvedVariables(undefined));
|
||||
|
||||
expect(useDashboardStore.getState().resolvedVariables).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
@@ -25,6 +26,7 @@ interface UseCreatePanelResult {
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
@@ -48,9 +50,11 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
safeNavigate(
|
||||
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
[safeNavigate, dashboardId, layoutIndex, search],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface UseGetQueryRangeV5Args {
|
||||
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
|
||||
* APIError — that happens later at the display boundary), so inspect it at the axios level.
|
||||
*/
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
export function retryUnlessClientError(
|
||||
failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. The optional `handoffState` is passed as
|
||||
* router location state — the View modal uses it to hand its drilldown-edited spec off to the
|
||||
* editor (view → edit) so the editor opens on those edits rather than the saved panel.
|
||||
* caller can open the editor with just the panel id. The `?variables=` selection is carried
|
||||
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
|
||||
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
|
||||
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
|
||||
* panel.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
})}${withVariablesSearch('', search)}`,
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
[safeNavigate, dashboardId, search],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ import { useDashboardStore } from '../store/useDashboardStore';
|
||||
* panel queries and triggers a refetch with the new values.
|
||||
*/
|
||||
export function useResolvedVariables(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): void {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const definitions = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
@@ -172,9 +172,43 @@ const GRID_COLS = 12;
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if there's room,
|
||||
* else wrap to a fresh row at the bottom. Only the last row is considered (items
|
||||
* sharing the greatest top-y); gaps in earlier rows are left alone.
|
||||
* Whether two grid rectangles intersect on both axes. Mirrors the backend's
|
||||
* overlap check (a patch placing two intersecting items is rejected), so this is
|
||||
* the authority the frontend must satisfy before adding an item.
|
||||
*/
|
||||
export function itemsOverlap(a: PlacedItem, b: PlacedItem): boolean {
|
||||
const ax = a.x ?? 0;
|
||||
const ay = a.y ?? 0;
|
||||
const aw = a.width ?? 0;
|
||||
const ah = a.height ?? 0;
|
||||
const bx = b.x ?? 0;
|
||||
const by = b.y ?? 0;
|
||||
const bw = b.width ?? 0;
|
||||
const bh = b.height ?? 0;
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh row below every existing item (`x: 0` at the greatest bottom-y) — sits
|
||||
* under everything so it can never overlap. Used when a panel must go to the end
|
||||
* (e.g. moved into a section) rather than backfilling a gap.
|
||||
*/
|
||||
export function bottomRowSlot(items: PlacedItem[]): { x: number; y: number } {
|
||||
const bottom = items.reduce(
|
||||
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
|
||||
0,
|
||||
);
|
||||
return { x: 0, y: bottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if it both fits
|
||||
* the grid width and clears every existing item, else wrap to a fresh row at the
|
||||
* bottom (`bottomRowSlot`). Only the last row is preferred (items sharing the
|
||||
* greatest top-y); gaps in earlier rows are left alone. The overlap guard is what
|
||||
* keeps this safe — a tall panel from an earlier row can reach down into the last
|
||||
* row, so "right of the last row" is not automatically free. This is the placement
|
||||
* primitive for create / clone.
|
||||
*/
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
@@ -185,19 +219,25 @@ export function findFreeSlot(
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const bottom = items.reduce(
|
||||
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
|
||||
0,
|
||||
);
|
||||
const lastRowY = items.reduce((max, it) => Math.max(max, it.y ?? 0), 0);
|
||||
const lastRowRightEdge = items
|
||||
.filter((it) => (it.y ?? 0) === lastRowY)
|
||||
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
|
||||
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
// height is unbounded downward, so use 1 for the fit probe: overlap on the
|
||||
// y-axis is decided by items reaching below `lastRowY`, not by the new
|
||||
// panel's own height (its top sits at the greatest top-y of all items).
|
||||
const candidate: PlacedItem = {
|
||||
x: lastRowRightEdge,
|
||||
y: lastRowY,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
const fitsWidth = lastRowRightEdge + w <= GRID_COLS;
|
||||
if (fitsWidth && !items.some((it) => itemsOverlap(candidate, it))) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return { x: 0, y: bottom };
|
||||
return bottomRowSlot(items);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { PanelTable, PanelTableColumn } from './types';
|
||||
|
||||
// Narrow view over a builder-query aggregation; envelope spec is `unknown`, so naming reads
|
||||
// through this view with a localized cast at the boundary.
|
||||
interface AggregationView {
|
||||
export interface AggregationView {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
}
|
||||
@@ -108,8 +108,25 @@ function getColName(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable row-data key (port of V1 `getColId`). Multi-aggregation queries need
|
||||
* `queryName.expression` so value columns don't collide.
|
||||
* The map key a value column's data is stored and looked up under in each row —
|
||||
* effectively the column id. Single-aggregation queries use the query name;
|
||||
* multi-aggregation queries append `.expression` (`queryName.expression`) so the
|
||||
* two value columns from one query don't collide on the same key.
|
||||
*/
|
||||
export function getAggregationColumnKey(
|
||||
queryName: string,
|
||||
aggregations: AggregationView[] | undefined,
|
||||
aggregationIndex = 0,
|
||||
): string {
|
||||
const expression = aggregations?.[aggregationIndex]?.expression || '';
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
return queryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable row-data key (port of V1 `getColId`).
|
||||
*/
|
||||
function getColId(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
@@ -129,12 +146,11 @@ function getColId(
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
return queryName;
|
||||
return getAggregationColumnKey(
|
||||
queryName,
|
||||
aggregationsPerQuery[queryName],
|
||||
col.aggregationIndex ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrepareScalarTablesArgs {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* One-shot reveal signal: a flow records the id of the panel/section to reveal and the
|
||||
* matching component scrolls itself into view on mount (see `useScrollIntoView`), then
|
||||
* clears it. Panel and section ids share this slot — they're in disjoint namespaces
|
||||
* (`sec-*` vs UUIDs). Kept off `useDashboardStore`/the URL so a refresh doesn't re-fire it.
|
||||
*/
|
||||
export interface ScrollIntoViewStore {
|
||||
scrollTargetId: string | null;
|
||||
setScrollTargetId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useScrollIntoViewStore = create<ScrollIntoViewStore>((set) => ({
|
||||
scrollTargetId: null,
|
||||
setScrollTargetId: (scrollTargetId): void => {
|
||||
set({ scrollTargetId });
|
||||
},
|
||||
}));
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
|
||||
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
@@ -42,11 +45,30 @@ function PanelEditorPage(): JSX.Element {
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
|
||||
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useDashboardFetch(dashboardId);
|
||||
// Derived here (not from the store) because the editor route doesn't mount
|
||||
// DashboardContainer, so the store's edit context may be cold on a direct URL.
|
||||
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
|
||||
const { isEditable, isLocked, canEditDashboard, editDisabledReason } =
|
||||
useDashboardEditGuard(dashboard);
|
||||
|
||||
// On a refresh/direct URL this route is the only mount, so seed the edit
|
||||
// context the way DashboardContainer does — during render, so the subtree's
|
||||
// first render already sees the id (useDashboardFetchRequired throws without it).
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
if (dashboard?.id) {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
refetch,
|
||||
});
|
||||
}
|
||||
|
||||
// No variables bar on this route: seed the selection and publish the resolved
|
||||
// payload so the preview and context links get variable values after a refresh.
|
||||
useSeedVariableSelection(dashboard);
|
||||
useResolvedVariables(dashboard);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
@@ -76,16 +98,11 @@ function PanelEditorPage(): JSX.Element {
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Carry only dashboard params; drop editor-only URL state (chiefly
|
||||
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
|
||||
const params = new URLSearchParams();
|
||||
const variables = new URLSearchParams(search).get(QueryParams.variables);
|
||||
if (variables) {
|
||||
params.set(QueryParams.variables, variables);
|
||||
}
|
||||
const query = params.toString();
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${
|
||||
query ? `?${query}` : ''
|
||||
}`,
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
|
||||
'',
|
||||
search,
|
||||
)}`,
|
||||
);
|
||||
}, [safeNavigate, dashboardId, search]);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
|
||||
// holding borderless buttons split by a divider.
|
||||
.refreshActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.refreshButton {
|
||||
display: flex;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import styles from './PublicAutoRefresh.module.scss';
|
||||
|
||||
// Reuse the app-wide auto-refresh popover styling.
|
||||
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
|
||||
|
||||
interface PublicAutoRefreshProps {
|
||||
enabled: boolean;
|
||||
/** Selected interval key, e.g. `30s`. */
|
||||
interval: string;
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (key: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
|
||||
// own time window, not Redux global time).
|
||||
function PublicAutoRefresh({
|
||||
enabled,
|
||||
interval,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
onIntervalChange,
|
||||
onRefresh,
|
||||
}: PublicAutoRefreshProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.refreshActions}>
|
||||
<div className={styles.refreshButton}>
|
||||
<Button
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
data-testid="public-dashboard-refresh"
|
||||
/>
|
||||
</div>
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="bottomRight"
|
||||
rootClassName="auto-refresh-root"
|
||||
trigger={['click']}
|
||||
content={
|
||||
<div className="auto-refresh-menu">
|
||||
<Checkbox
|
||||
onChange={(value): void => onToggle(value === true)}
|
||||
value={enabled}
|
||||
disabled={disabled}
|
||||
className="auto-refresh-checkbox"
|
||||
>
|
||||
Auto Refresh
|
||||
</Checkbox>
|
||||
<Typography.Text disabled={disabled} className="refresh-interval-text">
|
||||
Refresh Interval
|
||||
</Typography.Text>
|
||||
{refreshIntervalOptions
|
||||
.filter((option) => option.label !== 'off')
|
||||
.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
className="refresh-interval-btns"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => onIntervalChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
{option.key === interval && enabled && <Check size={14} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
title="Set auto refresh"
|
||||
data-testid="public-dashboard-auto-refresh"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicAutoRefresh;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import PublicAutoRefresh from '../PublicAutoRefresh';
|
||||
|
||||
const props = {
|
||||
enabled: false,
|
||||
interval: '30s',
|
||||
onToggle: jest.fn(),
|
||||
onIntervalChange: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PublicAutoRefresh', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the refresh and auto-refresh controls', () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('public-dashboard-auto-refresh'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRefresh when the refresh button is clicked', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes the interval from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByText('5 seconds'));
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
|
||||
});
|
||||
|
||||
it('toggles auto-refresh from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByRole('checkbox'));
|
||||
expect(props.onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l0-background);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: 8px 4px 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { layoutsToSections } from 'pages/DashboardPageV2/DashboardContainer/utils';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
|
||||
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
|
||||
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
|
||||
import styles from './PublicDashboardV2.module.scss';
|
||||
|
||||
interface PublicDashboardV2Props {
|
||||
publicDashboardId: string;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
|
||||
// Variables aren't rendered — the public endpoint doesn't substitute them.
|
||||
function PublicDashboardV2({
|
||||
publicDashboardId,
|
||||
data,
|
||||
}: PublicDashboardV2Props): JSX.Element {
|
||||
const { dashboard, publicDashboard } = data;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
|
||||
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
|
||||
);
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
|
||||
getStartTimeAndEndTimeFromTimeRange(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
),
|
||||
);
|
||||
|
||||
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
|
||||
|
||||
const handleTimeChange = (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
): void => {
|
||||
if (dateTimeRange) {
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else if (interval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
});
|
||||
}
|
||||
setSelectedTimeRangeLabel(interval as string);
|
||||
};
|
||||
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
|
||||
|
||||
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
|
||||
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
|
||||
const refreshIntervalMs = useMemo(
|
||||
() =>
|
||||
autoRefreshEnabled
|
||||
? refreshIntervalOptions.find(
|
||||
(option) => option.key === autoRefreshInterval,
|
||||
)?.value || 0
|
||||
: 0,
|
||||
[autoRefreshEnabled, autoRefreshInterval],
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => handleTimeChange(selectedTimeRangeLabel as Time),
|
||||
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
|
||||
);
|
||||
|
||||
const handleRefresh = (): void =>
|
||||
handleTimeChange(selectedTimeRangeLabel as Time);
|
||||
|
||||
const startMs = selectedTimeRange.startTime * 1000;
|
||||
const endMs = selectedTimeRange.endTime * 1000;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.brand}>
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" className={styles.brandLogo} />
|
||||
<Typography className={styles.brandName}>SigNoz</Typography>
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>
|
||||
{dashboard?.spec?.display?.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className={styles.headerRight}>
|
||||
<PublicAutoRefresh
|
||||
enabled={autoRefreshEnabled}
|
||||
interval={autoRefreshInterval}
|
||||
disabled={isAutoRefreshPaused}
|
||||
onToggle={setAutoRefreshEnabled}
|
||||
onIntervalChange={setAutoRefreshInterval}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
|
||||
isModalTimeSelection
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<section key={section.id} className={styles.section}>
|
||||
{section.title && (
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<PublicSectionGrid
|
||||
items={section.items}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardV2;
|
||||
@@ -0,0 +1,10 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { noop } from 'lodash-es';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
|
||||
import styles from './PublicPanel.module.scss';
|
||||
|
||||
interface PublicPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the panel is on screen — gates the fetch. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
|
||||
syncMode: DashboardCursorSync.None,
|
||||
};
|
||||
|
||||
// Read-only v2 public panel: reuses the V2 header/body renderers with interactions disabled.
|
||||
function PublicPanel({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
}: PublicPanelProps): JSX.Element {
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled: isVisible !== false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.panel} data-panel-root={panelKey}>
|
||||
<PanelHeader
|
||||
panelId={panelKey}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={noop}
|
||||
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicPanel;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
|
||||
import PublicPanel from '../PublicPanel';
|
||||
|
||||
jest.mock('../../hooks/usePublicPanelQuery', () => ({
|
||||
usePublicPanelQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
|
||||
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
enableDrillDown,
|
||||
panelId,
|
||||
}: {
|
||||
enableDrillDown?: boolean;
|
||||
panelId: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="panel-body"
|
||||
data-drilldown={String(!!enableDrillDown)}
|
||||
data-panel-id={panelId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockQuery = usePublicPanelQuery as jest.Mock;
|
||||
|
||||
const queryResult = {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
};
|
||||
|
||||
const timeseriesPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const commonProps = {
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('PublicPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockQuery.mockReturnValue(queryResult);
|
||||
});
|
||||
|
||||
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
const body = screen.getByTestId('panel-body');
|
||||
expect(body).toHaveAttribute('data-drilldown', 'false');
|
||||
expect(body).toHaveAttribute('data-panel-id', 'p1');
|
||||
});
|
||||
|
||||
it('fetches by panel key and time', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('gates the fetch when off screen', () => {
|
||||
render(
|
||||
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GridItem } from 'pages/DashboardPageV2/DashboardContainer/utils';
|
||||
import GridLayout, { type Layout, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
import PublicPanel from '../PublicPanel/PublicPanel';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface PublicSectionGridProps {
|
||||
items: GridItem[];
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the section is on screen — forwarded to gate panel fetches. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// Fixed (non-editable) grid for one section of a v2 public dashboard.
|
||||
function PublicSectionGrid({
|
||||
items,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
}: PublicSectionGridProps): JSX.Element {
|
||||
const layout: Layout[] = items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
static: true,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={layout}
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// Empty cell for an orphan layout item (panel id missing from the map).
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<PublicPanel
|
||||
panel={item.panel}
|
||||
panelKey={item.id}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicSectionGrid;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardV2 from '../PublicDashboardV2';
|
||||
|
||||
const mockGrid = jest.fn();
|
||||
|
||||
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockGrid(props);
|
||||
return <div data-testid="public-section-grid" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="datetime-selection" />,
|
||||
}));
|
||||
jest.mock('../PublicAutoRefresh/PublicAutoRefresh', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="auto-refresh" />,
|
||||
}));
|
||||
|
||||
function buildData(
|
||||
timeRangeEnabled: boolean,
|
||||
): DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
return {
|
||||
dashboard: {
|
||||
schemaVersion: 'v6',
|
||||
spec: {
|
||||
display: { name: 'My V2 Dashboard' },
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: {
|
||||
display: { title: 'Section A' },
|
||||
items: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: '#/spec/panels/p1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
panels: {
|
||||
p1: {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'p1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
|
||||
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
describe('PublicDashboardV2', () => {
|
||||
beforeEach(() => mockGrid.mockReset());
|
||||
|
||||
it('renders the dashboard title, section title, and a grid per section', () => {
|
||||
render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('My V2 Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Section A')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('public-section-grid')).toBeInTheDocument();
|
||||
|
||||
const gridProps = mockGrid.mock.calls[0][0];
|
||||
expect(gridProps.publicDashboardId).toBe('pub-1');
|
||||
expect(gridProps.items).toHaveLength(1);
|
||||
expect(gridProps.items[0].id).toBe('p1');
|
||||
expect(typeof gridProps.startMs).toBe('number');
|
||||
expect(typeof gridProps.endMs).toBe('number');
|
||||
// Times are handed to the endpoint in milliseconds.
|
||||
expect(gridProps.endMs).toBeGreaterThan(gridProps.startMs);
|
||||
});
|
||||
|
||||
it('shows the time controls only when the publisher enabled the time range', () => {
|
||||
const { rerender } = render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(false)} />,
|
||||
);
|
||||
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { usePublicPanelQuery } from '../usePublicPanelQuery';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardPanelQueryRangeV2: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = getPublicDashboardPanelQueryRangeV2 as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
// A timeseries panel with a single runnable builder query (non-metrics signal → runnable).
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { visualization: {} } },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
name: 'A',
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: { name: 'A', signal: 'logs', legend: 'My legend' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const args = {
|
||||
panel,
|
||||
panelKey: 'panel-1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('usePublicPanelQuery', () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('fetches by panel key + time and exposes the response as PanelQueryData', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { type: 'time_series', data: { results: [] } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePublicPanelQuery(args), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
{ id: 'pub-1', key: 'panel-1' },
|
||||
{ startTime: '1000', endTime: '2000' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.current.data.response?.status).toBe('success');
|
||||
expect(result.current.data.legendMap).toStrictEqual({ A: 'My legend' });
|
||||
expect(result.current.data.requestPayload?.start).toBe(1000);
|
||||
expect(result.current.data.requestPayload?.end).toBe(2000);
|
||||
// The public endpoint has no paging support.
|
||||
expect(result.current.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fetch without a public dashboard id', () => {
|
||||
renderHook(() => usePublicPanelQuery({ ...args, publicDashboardId: '' }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch when the panel has no runnable queries', () => {
|
||||
const emptyPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'empty' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
renderHook(() => usePublicPanelQuery({ ...args, panel: emptyPanel }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch when disabled', () => {
|
||||
renderHook(() => usePublicPanelQuery({ ...args, enabled: false }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelPagination,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export interface UsePublicPanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** Gate the fetch (default true). */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Same shape as `usePanelQuery` so the V2 renderers consume it unchanged.
|
||||
export interface UsePublicPanelQueryResult {
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPreviousData: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
cancelQuery: () => void;
|
||||
/** Always undefined — the public endpoint has no paging (#5557). */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one v2 public panel by key + time. The public endpoint holds the query server-side
|
||||
* (no body, variables, or paging); we still build the request DTO locally so the renderers get
|
||||
* the `requestPayload`/`legendMap` they need — it is not sent.
|
||||
*/
|
||||
export function usePublicPanelQuery({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled = true,
|
||||
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const { queries } = panel.spec;
|
||||
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
const visualization =
|
||||
pluginSpec && 'visualization' in pluginSpec
|
||||
? pluginSpec.visualization
|
||||
: undefined;
|
||||
const fillGaps = Boolean(
|
||||
visualization && 'fillSpans' in visualization && visualization.fillSpans,
|
||||
);
|
||||
|
||||
// For the renderers only — not sent to the server.
|
||||
const requestPayload = useMemo(
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
variables: {},
|
||||
}),
|
||||
[queries, panelType, startMs, endMs, fillGaps],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
|
||||
|
||||
// Redacted payloads are identical across panels — key on panel + time to avoid cache collisions.
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_WIDGET_DATA,
|
||||
publicDashboardId,
|
||||
panelKey,
|
||||
startMs,
|
||||
endMs,
|
||||
],
|
||||
[publicDashboardId, panelKey, startMs, endMs],
|
||||
);
|
||||
|
||||
const response = useQuery<GetPublicDashboardPanelQueryRangeV2200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2(
|
||||
{ id: publicDashboardId, key: panelKey },
|
||||
{ startTime: String(startMs), endTime: String(endMs) },
|
||||
signal,
|
||||
),
|
||||
enabled: enabled && runnable && !!publicDashboardId && !!panelKey,
|
||||
retry: retryUnlessClientError,
|
||||
});
|
||||
|
||||
const data = useMemo<PanelQueryData>(
|
||||
() => ({ response: response.data, requestPayload, legendMap }),
|
||||
[response.data, requestPayload, legendMap],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const cancelQuery = useCallback((): void => {
|
||||
void queryClient.cancelQueries(queryKey);
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: response.isLoading,
|
||||
isFetching: response.isFetching,
|
||||
isPreviousData: response.isPreviousData,
|
||||
error: response.error ?? null,
|
||||
refetch: response.refetch,
|
||||
cancelQuery,
|
||||
pagination: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
|
||||
|
||||
const UNIT_TO_DAYJS = {
|
||||
m: 'minutes',
|
||||
h: 'hours',
|
||||
d: 'days',
|
||||
w: 'weeks',
|
||||
} as const;
|
||||
|
||||
// Relative range (`30m`/`6h`/`7d`/`1w`) → `{ startTime, endTime }` in unix seconds; default 30m.
|
||||
export function getStartTimeAndEndTimeFromTimeRange(timeRange: string): {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
} {
|
||||
const match = timeRange.match(CUSTOM_TIME_REGEX);
|
||||
if (match) {
|
||||
const timeValue = parseInt(match[1] as string, 10);
|
||||
const unit = UNIT_TO_DAYJS[match[2] as keyof typeof UNIT_TO_DAYJS];
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, unit).unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: dayjs().subtract(30, 'minutes').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
|
||||
import { Frown } from '@signozhq/icons';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
|
||||
import PublicDashboardV2 from './PublicDashboardV2/PublicDashboardV2';
|
||||
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
@@ -14,27 +18,28 @@ function PublicDashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const {
|
||||
data: publicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardData(dashboardId || '');
|
||||
data: resolved,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useGetResolvedPublicDashboard(dashboardId || '');
|
||||
|
||||
const isLoading =
|
||||
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
|
||||
|
||||
const isError = isErrorPublicDashboardData;
|
||||
const isBusy = isLoading || isFetching;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-page">
|
||||
{publicDashboardData && (
|
||||
{resolved?.schema === PublicDashboardSchema.V2 && (
|
||||
<PublicDashboardV2 publicDashboardId={dashboardId} data={resolved.data} />
|
||||
)}
|
||||
|
||||
{resolved?.schema === PublicDashboardSchema.V1 && (
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={dashboardId}
|
||||
publicDashboardData={publicDashboardData}
|
||||
publicDashboardData={{ httpStatusCode: 200, data: resolved.data }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
{isError && !isBusy && (
|
||||
<div className="public-dashboard-error-container">
|
||||
<div className="perilin-bg" />
|
||||
|
||||
|
||||
@@ -466,6 +466,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
Summary: "Get query range result (v2)",
|
||||
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.PublicWidgetQueryRangeParams),
|
||||
RequestContentType: "",
|
||||
Response: new(querybuildertypesv5.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -418,7 +418,13 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
|
||||
params := new(dashboardtypes.PublicWidgetQueryRangeParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, params.StartTime, params.EndTime)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,12 @@ import (
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// PublicWidgetQueryRangeParams are the query params of the public panel query-range endpoint.
|
||||
type PublicWidgetQueryRangeParams struct {
|
||||
StartTime string `query:"startTime" required:"false"`
|
||||
EndTime string `query:"endTime" required:"false"`
|
||||
}
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
28
tests/integration/testdata/inframonitoring/kube_containers_dataset.jsonl
vendored
Normal file
28
tests/integration/testdata/inframonitoring/kube_containers_dataset.jsonl
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.reason": "ImagePullBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.reason": "Completed"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.reason": "OOMKilled"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
4
tests/integration/testdata/inframonitoring/kube_containers_missing_status.jsonl
vendored
Normal file
4
tests/integration/testdata/inframonitoring/kube_containers_missing_status.jsonl
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.15, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
16
tests/integration/testdata/inframonitoring/kube_containers_recency.jsonl
vendored
Normal file
16
tests/integration/testdata/inframonitoring/kube_containers_recency.jsonl
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
12
tests/integration/testdata/inframonitoring/kube_containers_state_fallback.jsonl
vendored
Normal file
12
tests/integration/testdata/inframonitoring/kube_containers_state_fallback.jsonl
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.05, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.reason": "Completed"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.05, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.reason": "ContainerCreating"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
12
tests/integration/testdata/inframonitoring/kube_containers_value_accuracy.jsonl
vendored
Normal file
12
tests/integration/testdata/inframonitoring/kube_containers_value_accuracy.jsonl
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.25, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.cpu_request_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.cpu_limit_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.memory_request_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.memory_limit_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.75, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.cpu_request_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.9, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.cpu_limit_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.7, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.memory_request_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.8, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.container.memory_limit_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
22
tests/integration/testdata/inframonitoring/kube_containers_value_accuracy_expected.json
vendored
Normal file
22
tests/integration/testdata/inframonitoring/kube_containers_value_accuracy_expected.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"podName": "acc1",
|
||||
"cpu": 0.25,
|
||||
"cpuRequestUtilization": 0.5,
|
||||
"cpuLimitUtilization": 0.3,
|
||||
"memory": 2000000,
|
||||
"memoryRequestUtilization": 0.6,
|
||||
"memoryLimitUtilization": 0.4
|
||||
},
|
||||
{
|
||||
"podName": "acc2",
|
||||
"cpu": 0.75,
|
||||
"cpuRequestUtilization": 0.9,
|
||||
"cpuLimitUtilization": 0.7,
|
||||
"memory": 5000000,
|
||||
"memoryRequestUtilization": 0.8,
|
||||
"memoryLimitUtilization": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -112,6 +112,14 @@ SPECS = {
|
||||
"optional": {},
|
||||
"attrs": {KAP: ["k8s.persistentvolumeclaim.name", "k8s.namespace.name"], RDP: ["k8s.cluster.name"]},
|
||||
},
|
||||
"kube_containers": {
|
||||
"default": {KSR: ["container.cpu.usage", "container.memory.working_set"], KCR: ["k8s.container.restarts", "k8s.container.ready"]},
|
||||
"optional": {
|
||||
KSR: ["k8s.container.cpu_request_utilization", "k8s.container.cpu_limit_utilization", "k8s.container.memory_request_utilization", "k8s.container.memory_limit_utilization"],
|
||||
KCR: ["k8s.container.status.state", "k8s.container.status.reason"],
|
||||
},
|
||||
"attrs": {KAP: ["k8s.pod.uid", "k8s.container.name"]},
|
||||
},
|
||||
}
|
||||
|
||||
ALL_TYPES = list(SPECS.keys())
|
||||
|
||||
425
tests/integration/tests/inframonitoring/12_kube_containers.py
Normal file
425
tests/integration/tests/inframonitoring/12_kube_containers.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""Integration tests for the v2 infra-monitoring kube_containers endpoint."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import compare_values, get_all_warnings
|
||||
|
||||
ENDPOINT = "/api/v2/infra_monitoring/kube_containers"
|
||||
|
||||
|
||||
def test_kube_containers_accuracy(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""Exact per-container usage/utilization values against precomputed expected
|
||||
output. (Status metrics are intentionally absent here, so status is no_data
|
||||
and a status warning is surfaced — this test asserts only the numeric fields.)"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_value_accuracy.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
with open(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_value_accuracy_expected.json"),
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
expected = json.load(f)
|
||||
exp_by_name = {r["podName"]: r for r in expected["records"]}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"filter": {"expression": "k8s.namespace.name = 'ns-acc'"},
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == len(expected["records"])
|
||||
assert {r["meta"]["k8s.pod.name"] for r in data["records"]} == set(exp_by_name.keys())
|
||||
|
||||
for record in data["records"]:
|
||||
exp = exp_by_name[record["meta"]["k8s.pod.name"]]
|
||||
for field in (
|
||||
"cpu",
|
||||
"cpuRequestUtilization",
|
||||
"cpuLimitUtilization",
|
||||
"memory",
|
||||
"memoryRequestUtilization",
|
||||
"memoryLimitUtilization",
|
||||
):
|
||||
assert compare_values(record[field], exp[field], 1e-9), f"{record['meta']['k8s.pod.name']}.{field}: got {record[field]}, expected {exp[field]}"
|
||||
|
||||
|
||||
def test_kube_containers_status_health_and_base_set(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""List mode: per-container status / ready / restarts derived from the
|
||||
k8s_cluster health metrics, AND the base-set union — containers with only
|
||||
health metrics and NO kubeletstats usage (ImagePullBackOff / Completed /
|
||||
OOMKilled) still appear, with cpu = -1 sentinel."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
# pod name -> (status, ready, restarts, has_usage)
|
||||
expected = {
|
||||
"crun": ("running", "ready", 0, True),
|
||||
"cnr": ("running", "not_ready", 0, True),
|
||||
"cclo": ("crashloopbackoff", "not_ready", 5, True),
|
||||
"cimg": ("imagepullbackoff", "not_ready", 0, False),
|
||||
"ccomp": ("completed", "not_ready", 0, False),
|
||||
"coom": ("oomkilled", "not_ready", 3, False),
|
||||
}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
|
||||
|
||||
assert set(expected).issubset(set(by_pod)), f"missing containers: {set(expected) - set(by_pod)}"
|
||||
for pod, (status, ready, restarts, has_usage) in expected.items():
|
||||
rec = by_pod[pod]
|
||||
assert rec["status"] == status, f"{pod}.status: got {rec['status']}, expected {status}"
|
||||
assert rec["ready"] == ready, f"{pod}.ready: got {rec['ready']}, expected {ready}"
|
||||
assert rec["restarts"] == restarts, f"{pod}.restarts: got {rec['restarts']}, expected {restarts}"
|
||||
if has_usage:
|
||||
assert rec["cpu"] != -1, f"{pod}: expected cpu populated, got {rec['cpu']}"
|
||||
else:
|
||||
assert rec["cpu"] == -1, f"{pod}: expected cpu -1 sentinel (base-set-only), got {rec['cpu']}"
|
||||
|
||||
|
||||
def test_kube_containers_status_counts_grouped_mode(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""Grouped mode (groupBy namespace): per-group counts of distinct containers
|
||||
by status and readiness."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["type"] == "grouped_list"
|
||||
by_ns = {r["meta"]["k8s.namespace.name"]: r for r in data["records"]}
|
||||
|
||||
assert {"ns-a", "ns-b"}.issubset(set(by_ns))
|
||||
ns_a = by_ns["ns-a"]["containerCountsByStatus"]
|
||||
assert ns_a["running"] == 2
|
||||
assert ns_a["crashLoopBackOff"] == 1
|
||||
assert ns_a["imagePullBackOff"] == 1
|
||||
assert by_ns["ns-a"]["containerCountsByReady"] == {"ready": 1, "notReady": 3}
|
||||
|
||||
ns_b = by_ns["ns-b"]["containerCountsByStatus"]
|
||||
assert ns_b["completed"] == 1
|
||||
assert ns_b["oomKilled"] == 1
|
||||
assert by_ns["ns-b"]["containerCountsByReady"] == {"ready": 0, "notReady": 2}
|
||||
|
||||
|
||||
def test_kube_containers_status_recency(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""Status derivation is by recency, not priority. A container that
|
||||
CrashLoopBackOff'd earlier in the window (restarts=5) but is Running now
|
||||
(state flipped to running, reason series flipped to 0) must report the
|
||||
CURRENT status 'running' — while restarts=5 preserves the crash history.
|
||||
A priority/argMax-on-reason bug would wrongly surface 'crashloopbackoff'."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_recency.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
|
||||
assert "crec" in by_pod, f"recency container missing: {list(by_pod)}"
|
||||
rec = by_pod["crec"]
|
||||
assert rec["status"] == "running", f"recency: expected current status running, got {rec['status']}"
|
||||
assert rec["ready"] == "ready", f"recency: expected ready, got {rec['ready']}"
|
||||
assert rec["restarts"] == 5, f"recency: expected restarts=5 (history preserved), got {rec['restarts']}"
|
||||
|
||||
|
||||
def test_kube_containers_status_state_only_fallback(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""When a container has a state but NO active reason, display status falls
|
||||
through to the state itself: state='terminated' -> 'terminated',
|
||||
state='waiting' -> 'waiting'. Exercises the state-fallback branches of the
|
||||
multiIf and the container_reason LEFT-JOIN miss (containers.go:410)."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_state_fallback.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
|
||||
assert {"cterm", "cwait"}.issubset(set(by_pod)), f"fallback containers missing: {list(by_pod)}"
|
||||
assert by_pod["cterm"]["status"] == "terminated", f"terminated fallback: got {by_pod['cterm']['status']}"
|
||||
assert by_pod["cwait"]["status"] == "waiting", f"waiting fallback: got {by_pod['cwait']['status']}"
|
||||
|
||||
|
||||
def test_kube_containers_status_warning_missing_metrics(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""Status metrics (k8s.container.status.state/reason) never ingested: the
|
||||
container still appears (usage present), status gates to 'no_data' with a
|
||||
non-blocking warning naming the missing metrics, while the independent
|
||||
ready / restarts health signals are still computed correctly."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_missing_status.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
data = body["data"]
|
||||
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
|
||||
assert "cwarn" in by_pod, f"container missing: {list(by_pod)}"
|
||||
rec = by_pod["cwarn"]
|
||||
assert rec["status"] == "no_data", f"expected status no_data (metrics disabled), got {rec['status']}"
|
||||
assert rec["ready"] == "ready", f"ready should still compute, got {rec['ready']}"
|
||||
assert rec["restarts"] == 2, f"restarts should still compute, got {rec['restarts']}"
|
||||
assert rec["cpu"] != -1, f"usage present, cpu should be populated, got {rec['cpu']}"
|
||||
|
||||
warnings = get_all_warnings(body)
|
||||
assert any("status.state" in w["message"] and "status.reason" in w["message"] for w in warnings), f"status warning naming the missing metrics not surfaced: {warnings!r}"
|
||||
|
||||
|
||||
def test_kube_containers_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""Filter expression narrows the result set by resource attribute."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def pods_for(expr: str) -> set[str]:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"filter": {"expression": expr},
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
pods = {r["meta"]["k8s.pod.name"] for r in data["records"]}
|
||||
assert data["total"] == len(pods)
|
||||
return pods
|
||||
|
||||
assert pods_for("k8s.namespace.name = 'ns-a'") == {"crun", "cnr", "cclo", "cimg"}
|
||||
assert pods_for("k8s.namespace.name = 'ns-b'") == {"ccomp", "coom"}
|
||||
assert pods_for("k8s.pod.name = 'crun'") == {"crun"}
|
||||
|
||||
|
||||
def test_kube_containers_orderby_and_pagination(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
) -> None:
|
||||
"""orderBy cpu desc ranks usage containers by cpu (crun>cnr>cclo) with the
|
||||
usage-less base-set (cpu=-1) backfilled last; pagination walks the full set
|
||||
(total=6) in non-overlapping pages preserving that order."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def page(limit: int, offset: int) -> dict:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"orderBy": {"key": {"name": "cpu"}, "direction": "desc"},
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
return response.json()["data"]
|
||||
|
||||
full = page(50, 0)
|
||||
assert full["total"] == 6
|
||||
order = [r["meta"]["k8s.pod.name"] for r in full["records"]]
|
||||
assert order[:3] == ["crun", "cnr", "cclo"], f"cpu-desc ranking wrong: {order}"
|
||||
assert set(order) == {"crun", "cnr", "cclo", "cimg", "ccomp", "coom"}
|
||||
|
||||
# Paginate 2 at a time; pages must be disjoint, cover everything, keep order.
|
||||
seen: list[str] = []
|
||||
for off in (0, 2, 4):
|
||||
p = page(2, off)
|
||||
assert p["total"] == 6, f"total must stay 6 at offset {off}, got {p['total']}"
|
||||
pods = [r["meta"]["k8s.pod.name"] for r in p["records"]]
|
||||
assert len(pods) == 2, f"offset {off}: expected 2 records, got {pods}"
|
||||
seen.extend(pods)
|
||||
assert seen == order, f"paginated sequence {seen} != full-page order {order}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload_override", "err_substr"),
|
||||
[
|
||||
pytest.param({"limit": 0}, "limit must be between", id="limit_zero"),
|
||||
pytest.param({"limit": 5001}, "limit must be between", id="limit_too_large"),
|
||||
pytest.param({"offset": -1}, "offset cannot be negative", id="offset_negative"),
|
||||
pytest.param(
|
||||
{"orderBy": {"key": {"name": "bogus"}, "direction": "desc"}},
|
||||
"invalid order by key",
|
||||
id="orderby_invalid_key",
|
||||
),
|
||||
pytest.param(
|
||||
{"orderBy": {"key": {"name": "cpu"}, "direction": "up"}},
|
||||
"invalid order by direction",
|
||||
id="orderby_invalid_direction",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"orderBy": {"key": {"name": "k8s.container.name"}, "direction": "asc"},
|
||||
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
|
||||
},
|
||||
"is only allowed when groupBy is empty",
|
||||
id="orderby_container_name_with_groupby",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_kube_containers_validation_errors(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
payload_override: dict,
|
||||
err_substr: str,
|
||||
) -> None:
|
||||
"""PostableContainers.Validate() rules reject with 400 + descriptive error."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
body: dict = {
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
}
|
||||
body.update(payload_override)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(signoz.self.host_configs["8080"].get(ENDPOINT), headers={"authorization": f"Bearer {token}"}, json=body, timeout=5)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
error = response.json()["error"]
|
||||
assert error["code"] == "invalid_input"
|
||||
assert err_substr.lower() in error["message"].lower(), f"expected {err_substr!r} in: {error['message']!r}"
|
||||
Reference in New Issue
Block a user