mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
5 Commits
issue_5015
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d20afff2c9 | ||
|
|
2f70f991df | ||
|
|
67fd0b889c | ||
|
|
84c61ad6b8 | ||
|
|
3a5e744b49 |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -45,15 +45,9 @@ jobs:
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -76,15 +76,9 @@ jobs:
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
19
Makefile
19
Makefile
@@ -34,8 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
|
||||
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
|
||||
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
|
||||
JS_BUILD_CONTEXT = $(SRC)/frontend
|
||||
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
|
||||
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR ?= /tmp/signoz-integration-buildx-cache
|
||||
|
||||
##############################################################
|
||||
# directories
|
||||
@@ -212,7 +210,7 @@ py-lint: ## Run ruff check across the shared tests project
|
||||
|
||||
.PHONY: py-test-setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
|
||||
@cd tests && SIGNOZ_INTEGRATION_BUILD_CACHE_DIR=$(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
|
||||
.PHONY: py-test-teardown
|
||||
py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
@@ -231,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@echo ">> python cache cleaned"
|
||||
|
||||
.PHONY: py-docker-clean
|
||||
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
|
||||
@echo ">> removing SigNoz integration test image"
|
||||
@docker image rm -f signoz:integration 2>/dev/null || true
|
||||
@echo ">> removing local integration buildx cache directories"
|
||||
@rm -rf $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR)-next
|
||||
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
|
||||
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
|
||||
|
||||
.PHONY: py-test-clean
|
||||
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
|
||||
@$(MAKE) py-test-teardown || true
|
||||
@$(MAKE) py-clean
|
||||
@$(MAKE) py-docker-clean
|
||||
|
||||
|
||||
##############################################################
|
||||
# generate commands
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -23,8 +21,7 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
RUN go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
@@ -32,8 +30,7 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
RUN go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -4189,7 +4189,6 @@ components:
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
- kube_containers
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
@@ -4295,158 +4294,6 @@ 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:
|
||||
@@ -5121,32 +4968,6 @@ 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:
|
||||
@@ -16278,86 +16099,6 @@ paths:
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/kube_containers:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
|
||||
metrics: CPU usage (cores), CPU request/limit utilization, memory working
|
||||
set, and memory request/limit utilization. Each container also reports health
|
||||
signals from the k8s_cluster receiver: status (kubectl-style display status
|
||||
derived from k8s.container.status.state + k8s.container.status.reason), restarts
|
||||
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
|
||||
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
|
||||
stable across container restarts. Each container includes metadata attributes
|
||||
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
|
||||
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
|
||||
as deployment/statefulset/daemonset/job). The response type is ''list'' for
|
||||
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
|
||||
with its current status and ready state) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates containers in the group with per-status counts under
|
||||
containerCountsByStatus, per-readiness counts under containerCountsByReady,
|
||||
and restarts as the group sum). Status requires the optional k8s.container.status.state
|
||||
and k8s.container.status.reason metrics; when either is missing, status is
|
||||
omitted and a warning is returned while restarts and ready are still computed.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by any
|
||||
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
|
||||
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
|
||||
and restarts return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListContainers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableContainersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
@@ -32,7 +31,6 @@ import type {
|
||||
InframonitoringtypesPostableStatefulSetsDTO,
|
||||
InframonitoringtypesPostableVolumesDTO,
|
||||
ListClusters200,
|
||||
ListContainers200,
|
||||
ListDaemonSets200,
|
||||
ListDeployments200,
|
||||
ListHosts200,
|
||||
@@ -550,89 +548,6 @@ 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,7 +5577,6 @@ export enum InframonitoringtypesCheckTypeDTO {
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
kube_containers = 'kube_containers',
|
||||
}
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
@@ -5857,174 +5856,6 @@ 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;
|
||||
};
|
||||
@@ -6607,33 +6438,6 @@ 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
|
||||
@@ -11237,14 +11041,6 @@ export type ListJobs200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListContainers200 = {
|
||||
data: InframonitoringtypesContainersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNamespaces200 = {
|
||||
data: InframonitoringtypesNamespacesDTO;
|
||||
/**
|
||||
|
||||
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
<ExportPanelContainer
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
onClose={onCancel(false)}
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,10 +179,8 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
// Click the "New dashboard" button
|
||||
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
@@ -229,13 +227,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -251,14 +248,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
@@ -326,13 +322,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -348,14 +343,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
|
||||
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { getSelectOptions } from './utils';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
interface ExportDashboardSelectProps {
|
||||
dashboards: Dashboard[];
|
||||
value: string | null;
|
||||
/** The picked dashboard, pinned as an option so its label survives a later search. */
|
||||
selectedDashboard: Dashboard | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (dashboardId: string) => void;
|
||||
onSearch: (search: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
|
||||
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
|
||||
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
|
||||
* dropdown.
|
||||
*/
|
||||
function ExportDashboardSelect({
|
||||
dashboards,
|
||||
value,
|
||||
selectedDashboard,
|
||||
loading,
|
||||
disabled,
|
||||
onChange,
|
||||
onSearch,
|
||||
}: ExportDashboardSelectProps): JSX.Element {
|
||||
const options = useMemo<SelectProps['options']>(() => {
|
||||
const base = getSelectOptions(dashboards) ?? [];
|
||||
if (
|
||||
selectedDashboard &&
|
||||
!base.some((option) => option.value === selectedDashboard.id)
|
||||
) {
|
||||
return [
|
||||
{ label: selectedDashboard.data.title, value: selectedDashboard.id },
|
||||
...base,
|
||||
];
|
||||
}
|
||||
return base;
|
||||
}, [dashboards, selectedDashboard]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={styles.dashboardSelect}
|
||||
placeholder="Select a dashboard"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
data-testid="export-dashboard-select"
|
||||
options={options}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ExportDashboardSelect.defaultProps = {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ExportDashboardSelect;
|
||||
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.dashboardSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newDashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,127 +1,155 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import createDashboard from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
|
||||
import { useExportDashboards } from 'hooks/dashboard/useExportDashboards';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ExportPanelProps } from '.';
|
||||
import {
|
||||
DashboardSelect,
|
||||
NewDashboardButton,
|
||||
SelectWrapper,
|
||||
Title,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { filterOptions, getSelectOptions } from './utils';
|
||||
import ExportDashboardSelect from './ExportDashboardSelect';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
/** Controlled open state of the dialog. */
|
||||
open: boolean;
|
||||
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
|
||||
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
|
||||
*/
|
||||
function ExportPanelContainer({
|
||||
isLoading,
|
||||
onExport,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
// Track the object, not just the id, so export survives a search that narrows it out.
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard | null>(
|
||||
null,
|
||||
);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
dashboards,
|
||||
isLoading: isAllDashboardsLoading,
|
||||
refetch,
|
||||
} = useGetAllDashboard();
|
||||
isFetching: isDashboardsFetching,
|
||||
} = useExportDashboards(searchText);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useMutation(createDashboard, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onExport(data?.data, true);
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
const { create: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useCreateExportDashboard({
|
||||
title: t('new_dashboard_title', { ns: 'dashboard' }),
|
||||
onCreated: (dashboard) => onExport(dashboard, true),
|
||||
});
|
||||
|
||||
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, dashboardId, onExport]);
|
||||
// Reset on close so each open starts fresh (the dialog stays mounted).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedDashboard(null);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
(dashboardId: string): void => {
|
||||
setSelectedDashboard(
|
||||
dashboards.find(({ id }) => id === dashboardId) ?? null,
|
||||
);
|
||||
},
|
||||
[setDashboardId],
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
try {
|
||||
await createNewDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [createNewDashboard, t, showErrorModal]);
|
||||
const handleExportClick = useCallback((): void => {
|
||||
onExport(selectedDashboard, false);
|
||||
}, [selectedDashboard, onExport]);
|
||||
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
const isExportDisabled =
|
||||
isAllDashboardsLoading || !selectedDashboard || isLoading;
|
||||
|
||||
return (
|
||||
<Wrapper direction="vertical">
|
||||
<Title>Export Panel</Title>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Add to dashboard"
|
||||
testId="export-panel-dialog"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="export-panel-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isExportDisabled}
|
||||
onClick={handleExportClick}
|
||||
testId="export-panel-export"
|
||||
>
|
||||
Add to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Select a dashboard
|
||||
</Typography.Text>
|
||||
<ExportDashboardSelect
|
||||
dashboards={dashboards}
|
||||
value={selectedDashboard?.id ?? null}
|
||||
selectedDashboard={selectedDashboard}
|
||||
loading={isDashboardsFetching}
|
||||
disabled={isAllDashboardsLoading || createDashboardLoading}
|
||||
onChange={handleSelect}
|
||||
onSearch={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectWrapper direction="horizontal">
|
||||
<DashboardSelect
|
||||
placeholder="Select Dashboard"
|
||||
options={options}
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SelectWrapper>
|
||||
|
||||
<Typography>
|
||||
Or create dashboard with this panel -
|
||||
<NewDashboardButton
|
||||
disabled={createDashboardLoading}
|
||||
loading={createDashboardLoading}
|
||||
type="link"
|
||||
onClick={handleNewDashboard}
|
||||
>
|
||||
New Dashboard
|
||||
</NewDashboardButton>
|
||||
</Typography>
|
||||
</Wrapper>
|
||||
<div className={styles.newDashboard}>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Or create a new dashboard with this panel
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
loading={createDashboardLoading}
|
||||
disabled={createDashboardLoading}
|
||||
onClick={createNewDashboard}
|
||||
testId="export-panel-new-dashboard"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ExportPanelContainer.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default ExportPanelContainer;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportPanelContainer from './ExportPanelContainer';
|
||||
|
||||
function ExportPanel({
|
||||
isLoading,
|
||||
onExport,
|
||||
query,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
}, []);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={query}
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
}
|
||||
|
||||
ExportPanel.defaultProps = { isLoading: false };
|
||||
|
||||
export default ExportPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Button, Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
|
||||
Select,
|
||||
)<SelectProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SelectWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NewDashboardButton = styled(Button)`
|
||||
&&& {
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography.Text)`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
@@ -6,11 +6,3 @@ export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
|
||||
label: data.title,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
export const filterOptions: SelectProps['filterOption'] = (
|
||||
input,
|
||||
options,
|
||||
): boolean =>
|
||||
(options?.label?.toString() ?? '')
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -52,7 +53,6 @@ import { Filter } from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import LogsActionsContainer from './LogsActionsContainer';
|
||||
@@ -76,6 +76,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
|
||||
@@ -286,7 +287,7 @@ function LogsExplorerViewsContainer({
|
||||
dashboardName: dashboard?.data?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query: exportDefaultQuery,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -295,7 +296,12 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[safeNavigate, exportDefaultQuery, selectedPanelType],
|
||||
[
|
||||
safeNavigate,
|
||||
exportDefaultQuery,
|
||||
selectedPanelType,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
@@ -35,7 +36,6 @@ import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { explorerViewToPanelType } from 'utils/explorerUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -63,6 +63,7 @@ function Explorer(): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
@@ -278,7 +279,7 @@ function Explorer(): JSX.Element {
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -287,7 +288,7 @@ function Explorer(): JSX.Element {
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, safeNavigate, yAxisUnit],
|
||||
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
|
||||
);
|
||||
|
||||
const splitedQueries = useMemo(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import createDashboardV1 from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useCreateExportDashboard } from '../useCreateExportDashboard';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
jest.mock('api/v1/dashboards/create');
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
createDashboardV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
const mockCreateV1 = createDashboardV1 as jest.Mock;
|
||||
const mockCreateV2 = createDashboardV2 as jest.Mock;
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const TITLE = 'My dashboard';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCreateExportDashboard', () => {
|
||||
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
const v1Dashboard = {
|
||||
id: 'v1-new',
|
||||
data: { title: TITLE },
|
||||
} as unknown as Dashboard;
|
||||
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
|
||||
const onCreated = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreateExportDashboard({ title: TITLE, onCreated }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => result.current.create());
|
||||
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(v1Dashboard));
|
||||
expect(mockCreateV1).toHaveBeenCalledWith({
|
||||
title: TITLE,
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
expect(mockCreateV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
|
||||
const onCreated = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useCreateExportDashboard({ title: TITLE, onCreated }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => result.current.create());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onCreated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'v2-new', data: { title: TITLE } }),
|
||||
),
|
||||
);
|
||||
expect(mockCreateV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 'v6',
|
||||
spec: expect.objectContaining({ display: { name: TITLE } }),
|
||||
}),
|
||||
);
|
||||
expect(mockCreateV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useExportDashboards } from '../useExportDashboards';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard');
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
|
||||
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
|
||||
|
||||
const v1Refetch = jest.fn();
|
||||
const v2Refetch = jest.fn();
|
||||
|
||||
const v1Dashboard = {
|
||||
id: 'v1-1',
|
||||
data: { title: 'V1 Dashboard' },
|
||||
} as unknown as Dashboard;
|
||||
|
||||
const v1Other = {
|
||||
id: 'v1-2',
|
||||
data: { title: 'Other board' },
|
||||
} as unknown as Dashboard;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetAllDashboard.mockReturnValue({
|
||||
data: { data: [v1Dashboard, v1Other] },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: v1Refetch,
|
||||
});
|
||||
mockUseListV2.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
dashboards: [
|
||||
{
|
||||
id: 'v2-1',
|
||||
name: 'V2 Dashboard',
|
||||
spec: { display: { name: 'V2 Dashboard' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: v2Refetch,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExportDashboards', () => {
|
||||
it('returns the V1 list and disables the V2 query when the flag is off', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([v1Dashboard, v1Other]);
|
||||
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: { enabled: false, keepPreviousData: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters the V1 list in memory by title (case-insensitive)', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards('v1 dash'));
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([v1Dashboard]);
|
||||
});
|
||||
|
||||
it('returns the V2 list normalized to the dashboard shape when the flag is on', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
expect(result.current.dashboards).toStrictEqual([
|
||||
expect.objectContaining({ id: 'v2-1', data: { title: 'V2 Dashboard' } }),
|
||||
]);
|
||||
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: undefined }),
|
||||
expect.objectContaining({
|
||||
query: { enabled: true, keepPreviousData: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
renderHook(() => useExportDashboards('payments'));
|
||||
|
||||
expect(mockUseListV2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('refetches the active source', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useExportDashboards());
|
||||
|
||||
result.current.refetch();
|
||||
expect(v2Refetch).toHaveBeenCalledTimes(1);
|
||||
expect(v1Refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
|
||||
|
||||
jest.mock('hooks/useIsDashboardV2');
|
||||
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
|
||||
typeof useIsDashboardV2
|
||||
>;
|
||||
|
||||
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
|
||||
const params = {
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
widgetId: 'w1',
|
||||
};
|
||||
|
||||
describe('useGetExportToDashboardLink', () => {
|
||||
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useGetExportToDashboardLink());
|
||||
const link = result.current(params);
|
||||
|
||||
expect(link.startsWith('/dashboard/dash-1/new?')).toBe(true);
|
||||
expect(link).toContain('graphType=');
|
||||
expect(link).toContain('widgetId=w1');
|
||||
expect(link).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
|
||||
mockUseIsDashboardV2.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useGetExportToDashboardLink());
|
||||
const link = result.current(params);
|
||||
|
||||
expect(link.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
|
||||
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
|
||||
expect(link).not.toContain('widgetId');
|
||||
expect(link).toContain('compositeQuery=');
|
||||
});
|
||||
});
|
||||
88
frontend/src/hooks/dashboard/useCreateExportDashboard.ts
Normal file
88
frontend/src/hooks/dashboard/useCreateExportDashboard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import createDashboardV1 from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface UseCreateExportDashboardParams {
|
||||
title: string;
|
||||
/** Receives the created dashboard (normalized) for the caller to navigate/export. */
|
||||
onCreated: (dashboard: Dashboard) => void;
|
||||
}
|
||||
|
||||
interface UseCreateExportDashboardResult {
|
||||
create: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
|
||||
* `createDashboardV2`; V1 uses the legacy create. Both normalize to a `Dashboard`.
|
||||
*/
|
||||
export function useCreateExportDashboard({
|
||||
title,
|
||||
onCreated,
|
||||
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onError = useCallback(
|
||||
(error: unknown): void => showErrorModal(error as APIError),
|
||||
[showErrorModal],
|
||||
);
|
||||
|
||||
const v1 = useMutation(createDashboardV1, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onCreated(data.data);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const v2 = useMutation(
|
||||
() =>
|
||||
createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: title },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
onCreated({
|
||||
id: created.data.id,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
locked: false,
|
||||
data: { title },
|
||||
} as Dashboard);
|
||||
},
|
||||
onError,
|
||||
},
|
||||
);
|
||||
|
||||
const create = useCallback((): void => {
|
||||
if (isDashboardV2) {
|
||||
v2.mutate();
|
||||
} else {
|
||||
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
|
||||
}
|
||||
}, [isDashboardV2, v1, v2, title]);
|
||||
|
||||
return {
|
||||
create,
|
||||
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
|
||||
};
|
||||
}
|
||||
88
frontend/src/hooks/dashboard/useExportDashboards.ts
Normal file
88
frontend/src/hooks/dashboard/useExportDashboards.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
const V2_LIST_LIMIT = 1000;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export interface UseExportDashboardsResult {
|
||||
dashboards: Dashboard[];
|
||||
/** First load only — disables the picker until there are options. */
|
||||
isLoading: boolean;
|
||||
/** Any fetch incl. a search refetch — drives the picker spinner. */
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function toDashboard(
|
||||
item: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): Dashboard {
|
||||
return {
|
||||
id: item.id,
|
||||
createdAt: item.createdAt ?? '',
|
||||
updatedAt: item.updatedAt ?? '',
|
||||
createdBy: item.createdBy ?? '',
|
||||
updatedBy: item.updatedBy ?? '',
|
||||
locked: item.locked,
|
||||
data: { title: item.spec.display?.name || item.name },
|
||||
};
|
||||
}
|
||||
|
||||
function filterByTitle(dashboards: Dashboard[], search: string): Dashboard[] {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return dashboards;
|
||||
}
|
||||
return dashboards.filter((dashboard) =>
|
||||
(dashboard.data.title ?? '').toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
|
||||
// as a name-contains clause (single quotes escaped).
|
||||
function toNameQuery(search: string): string | undefined {
|
||||
const term = search.trim();
|
||||
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware, search-filtered source for the "Add to dashboard" picker. V2 searches
|
||||
* server-side (debounced); V1 filters its already-complete list in memory.
|
||||
*/
|
||||
export function useExportDashboards(search = ''): UseExportDashboardsResult {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
const v2 = useListDashboardsForUserV2(
|
||||
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
|
||||
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const dashboards = useMemo<Dashboard[]>(
|
||||
() =>
|
||||
isDashboardV2
|
||||
? (v2.data?.data?.dashboards ?? []).map(toDashboard)
|
||||
: filterByTitle(v1.data?.data ?? [], search),
|
||||
[isDashboardV2, v1.data, v2.data, search],
|
||||
);
|
||||
|
||||
const refetch = useCallback((): void => {
|
||||
if (isDashboardV2) {
|
||||
void v2.refetch();
|
||||
} else {
|
||||
void v1.refetch();
|
||||
}
|
||||
}, [isDashboardV2, v1, v2]);
|
||||
|
||||
return {
|
||||
dashboards,
|
||||
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
|
||||
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
37
frontend/src/hooks/dashboard/useGetExportToDashboardLink.ts
Normal file
37
frontend/src/hooks/dashboard/useGetExportToDashboardLink.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
|
||||
interface ExportToDashboardLinkParams {
|
||||
dashboardId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
|
||||
* editor (`/dashboard/:id/panel/new`); V1 uses the legacy new-widget link. Keeps the V1
|
||||
* `generateExportToDashboardLink` signature (V2 ignores `widgetId` — the id is made on save).
|
||||
*/
|
||||
export function useGetExportToDashboardLink(): (
|
||||
params: ExportToDashboardLinkParams,
|
||||
) => string {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
return useCallback(
|
||||
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
|
||||
isDashboardV2
|
||||
? buildExportPanelLink({ dashboardId, panelType, query })
|
||||
: generateExportToDashboardLink({
|
||||
query,
|
||||
panelType,
|
||||
dashboardId,
|
||||
widgetId,
|
||||
}),
|
||||
[isDashboardV2],
|
||||
);
|
||||
}
|
||||
@@ -49,23 +49,6 @@ 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,20 +29,16 @@ export function insertVariableAtCursor(
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
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.
|
||||
// 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 {
|
||||
return safeDecodeURIComponent(safeDecodeURIComponent(value));
|
||||
const decoded = decodeURIComponent(value);
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
} catch {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
@@ -57,7 +53,7 @@ export function getUrlParams(url: string): UrlParam[] {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: safeDecodeURIComponent(key),
|
||||
key: decodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -25,7 +23,6 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -56,7 +53,6 @@ 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,7 +81,6 @@ 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 useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useMetricYAxisUnit'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,33 +100,4 @@ 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,7 +26,12 @@
|
||||
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);
|
||||
|
||||
@@ -35,13 +40,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,7 +7,6 @@ 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';
|
||||
@@ -46,8 +45,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +59,6 @@ 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];
|
||||
@@ -160,9 +156,7 @@ function PanelEditorQueryBuilder({
|
||||
<div className={styles.scrollArea}>
|
||||
<Tabs
|
||||
type="card"
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
className={styles.tabsContainer}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -20,7 +19,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('panel-1');
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -62,8 +61,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -105,17 +104,12 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -202,10 +196,7 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollIntoViewStore.setState({ scrollTargetId: null });
|
||||
});
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -308,32 +299,6 @@ 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,4 +1,8 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
buildExportPanelLink,
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
@@ -31,4 +35,56 @@ describe('newPanelRoute', () => {
|
||||
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('buildExportPanelLink', () => {
|
||||
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
|
||||
|
||||
const parseLink = (
|
||||
link: string,
|
||||
): { path: string; params: URLSearchParams } => {
|
||||
const [path, search] = link.split('?');
|
||||
return { path, params: new URLSearchParams(search) };
|
||||
};
|
||||
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
|
||||
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
|
||||
[PANEL_TYPES.LIST, 'signoz/ListPanel'],
|
||||
])('maps export panel type %s to kind %s', (panelType, expectedKind) => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType,
|
||||
query,
|
||||
});
|
||||
const { path, params } = parseLink(link);
|
||||
expect(path).toBe('/dashboard/dash-1/panel/new');
|
||||
expect(params.get('panelKind')).toBe(expectedKind);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, `?${params.toString()}`)).toBe(
|
||||
expectedKind,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query as a decodable compositeQuery param', () => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
});
|
||||
const { params } = parseLink(link);
|
||||
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
|
||||
query,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to TimeSeries for a panel type with no V2 kind', () => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.EMPTY_WIDGET,
|
||||
query,
|
||||
});
|
||||
expect(parseLink(link).params.get('panelKind')).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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,13 +247,11 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
|
||||
describe('datasource switch', () => {
|
||||
const withSource = (id: string, ...dataSources: string[]): Query =>
|
||||
const withSource = (id: string, dataSource: string): Query =>
|
||||
({
|
||||
id,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: dataSources.map((dataSource) => ({ dataSource })),
|
||||
},
|
||||
builder: { queryData: [{ dataSource }] },
|
||||
}) as unknown as Query;
|
||||
|
||||
it('commits the active query when a query datasource changes', () => {
|
||||
@@ -288,58 +286,6 @@ 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,8 +24,6 @@ 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();
|
||||
@@ -46,7 +44,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -55,25 +53,6 @@ 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', () => {
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
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,27 +111,17 @@ 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 dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
[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,8 +23,7 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -45,20 +44,17 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
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: savedPanelId,
|
||||
panelId: uuid(),
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -73,7 +69,6 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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,29 +8,25 @@ 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';
|
||||
@@ -81,7 +77,6 @@ function PanelEditorContainer({
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
@@ -128,20 +123,32 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// 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]);
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -167,10 +174,11 @@ function PanelEditorContainer({
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's default columns so the Columns control isn't empty.
|
||||
// Seed a new List panel's columns from the query's resolved signal (not the kind's
|
||||
// default logs signal) so a traces-List export gets traces columns, not logs.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: defaultSignal,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
@@ -180,17 +188,6 @@ 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 => {
|
||||
@@ -206,33 +203,19 @@ 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.
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [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]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -244,7 +227,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onCloseEditor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels share a fixed id segment, carrying kind + target section
|
||||
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
|
||||
// id is generated on save.
|
||||
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
|
||||
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
@@ -37,6 +43,31 @@ export function parseNewPanelKind(
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* New-panel editor link that exports an explorer query into a V2 dashboard: maps panel
|
||||
* type → kind (falls back to TimeSeries) and carries the raw `Query` as `compositeQuery`,
|
||||
* encoded as the V1 link so `useGetCompositeQueryParam` reads it identically. Conversion
|
||||
* happens in the editor; no `layoutIndex`, so exports land in the first section.
|
||||
*/
|
||||
export function buildExportPanelLink({
|
||||
dashboardId,
|
||||
panelType,
|
||||
query,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
}): string {
|
||||
const kind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? 'signoz/TimeSeriesPanel';
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
return `${path}${newPanelSearch(kind)}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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,36 +28,14 @@ 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,
|
||||
queries: unknown[] = [],
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
function oldSpecWith(pluginSpec: 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([]);
|
||||
@@ -69,15 +47,16 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Axes, 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 produces an empty slice (never key: undefined)', () => {
|
||||
it('omits the key entirely when a seed returns undefined (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
]);
|
||||
@@ -133,108 +112,6 @@ 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', () => {
|
||||
@@ -260,30 +137,6 @@ 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)', () => {
|
||||
@@ -301,7 +154,7 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the panel-wide unit when no column keys are derivable (TimeSeries → Table, no queries)', () => {
|
||||
it('drops unit when the target kind does not declare it (TimeSeries → Table)', () => {
|
||||
// Table formatting has columnUnits + decimals only; carrying unit breaks the save.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
@@ -318,56 +171,6 @@ 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 } },
|
||||
@@ -422,14 +225,12 @@ 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 }, queries),
|
||||
}).thresholds;
|
||||
return buildPluginSpec(sections, { oldSpec: oldSpecWith({ thresholds }) })
|
||||
.thresholds;
|
||||
}
|
||||
|
||||
it('keeps color/value/unit/label within the label variant (and defaults to label)', () => {
|
||||
@@ -457,55 +258,27 @@ describe('buildPluginSpec', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves operator/format and keys onto the first query column when remapping comparison → table', () => {
|
||||
it('preserves existing operator/format when remapping comparison → table', () => {
|
||||
expect(
|
||||
switchThresholds(
|
||||
ThresholdVariant.TABLE,
|
||||
[
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
[builderQueryNamed('A')],
|
||||
),
|
||||
switchThresholds(ThresholdVariant.TABLE, [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]),
|
||||
).toStrictEqual([
|
||||
{
|
||||
value: 80,
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
columnName: 'A',
|
||||
columnName: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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, [
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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,7 +13,6 @@ import {
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
type ControlledSectionKind,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -21,18 +20,13 @@ 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' | 'columnUnits'
|
||||
>;
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: SectionSpecMap[SectionKind.Columns];
|
||||
thresholds?: AnyThreshold[];
|
||||
}
|
||||
@@ -43,11 +37,6 @@ 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;
|
||||
@@ -62,7 +51,6 @@ interface AnyThresholdFields {
|
||||
function toThresholdVariant(
|
||||
source: AnyThresholdFields,
|
||||
variant: ThresholdVariant,
|
||||
defaultColumnName?: string,
|
||||
): AnyThreshold {
|
||||
const core = {
|
||||
color: source.color,
|
||||
@@ -81,7 +69,7 @@ function toThresholdVariant(
|
||||
...core,
|
||||
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
|
||||
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
|
||||
columnName: source.columnName || defaultColumnName || '',
|
||||
columnName: source.columnName ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -91,166 +79,97 @@ function toThresholdVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
interface SectionSeed {
|
||||
specKey: keyof SeededPluginSpec;
|
||||
seed: (controls: unknown, ctx: SeedContext) => unknown;
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
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 }),
|
||||
};
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
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 }
|
||||
: {};
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: '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 ?? {};
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (controls.lineStyle) {
|
||||
appearance.lineStyle = lineStyle;
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (controls.lineInterpolation) {
|
||||
appearance.lineInterpolation = lineInterpolation;
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (controls.fillMode) {
|
||||
appearance.fillMode = fillMode;
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (controls.showPoints && showPoints !== undefined) {
|
||||
appearance.showPoints = showPoints;
|
||||
}
|
||||
if (controls.spanGaps && spanGaps !== undefined) {
|
||||
appearance.spanGaps = spanGaps;
|
||||
}
|
||||
return appearance;
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Formatting]: {
|
||||
specKey: 'formatting',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec, oldPluginSpec },
|
||||
): NonNullable<SeededPluginSpec['formatting']> => {
|
||||
const old = oldPluginSpec?.formatting;
|
||||
{ oldSpec },
|
||||
): Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Formatting];
|
||||
const old = (oldSpec?.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
?.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: NonNullable<SeededPluginSpec['formatting']> = {
|
||||
...(controls.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(controls.decimals &&
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(c.unit && old?.unit !== undefined && { unit: old.unit }),
|
||||
...(c.decimals &&
|
||||
old?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: old.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
// 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;
|
||||
return Object.keys(carried).length > 0 ? carried : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Columns]: {
|
||||
specKey: 'selectFields',
|
||||
seed: (_controls, { signal }): SectionSpecMap[SectionKind.Columns] =>
|
||||
signal ? defaultColumnsForSignal(signal) : [],
|
||||
seed: (
|
||||
_controls,
|
||||
{ signal },
|
||||
): SectionSpecMap[SectionKind.Columns] | undefined => {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
return columns.length > 0 ? columns : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Thresholds]: {
|
||||
specKey: 'thresholds',
|
||||
seed: (controls, { oldSpec, oldPluginSpec }): AnyThreshold[] => {
|
||||
const variant = controls.variant ?? ThresholdVariant.LABEL;
|
||||
const old = oldPluginSpec?.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;
|
||||
if (!old || old.length === 0) {
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
// 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 old.map((threshold) =>
|
||||
toThresholdVariant(threshold as AnyThresholdFields, variant),
|
||||
);
|
||||
return variant === ThresholdVariant.TABLE
|
||||
? mapped.filter((t) => (t as { columnName?: string }).columnName)
|
||||
: mapped;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -265,23 +184,14 @@ 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;
|
||||
// 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)) {
|
||||
const value = entry.seed(controls, ctx);
|
||||
if (value !== undefined) {
|
||||
// specKey ↔ value correlation can't be proven across the lookup; one localized cast.
|
||||
(spec as Record<string, unknown>)[entry.specKey] = value;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
|
||||
/** A panel context link with its label and URL templates resolved, ready to render. */
|
||||
export interface ResolvedDrilldownLink {
|
||||
@@ -11,8 +10,10 @@ export interface ResolvedDrilldownLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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).
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardLinkDTO[] | undefined,
|
||||
@@ -23,17 +24,27 @@ 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) => {
|
||||
const rawLabel = link.name || link.url || '';
|
||||
const rawUrl = link.url ?? '';
|
||||
// Only an explicit `false` opts out; undefined defaults to substitution on.
|
||||
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
|
||||
if (link.renderVariables === false) {
|
||||
return { id: String(index), label: rawLabel, url: rawUrl };
|
||||
return {
|
||||
id: String(index),
|
||||
label: link.name || link.url || '',
|
||||
url: link.url ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: String(index),
|
||||
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
|
||||
url: resolveContextLinkUrl(rawUrl, processedVariables),
|
||||
id: resolved[index].id,
|
||||
label: resolved[index].label,
|
||||
url: resolved[index].url,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,7 +131,6 @@ 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/variablesUrlState',
|
||||
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
|
||||
() => ({
|
||||
ALL_SELECTED: '__ALL__',
|
||||
variablesUrlParser: { withOptions: (): unknown => ({}) },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -48,7 +47,6 @@ 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 () => {
|
||||
@@ -134,21 +132,6 @@ 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,7 +10,6 @@ import {
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
@@ -33,7 +32,6 @@ 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> => {
|
||||
@@ -66,9 +64,6 @@ 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
|
||||
@@ -79,6 +74,6 @@ export function useClonePanel({
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, patchAsync, setScrollTargetId],
|
||||
[sections, dashboardId, patchAsync],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
|
||||
|
||||
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 { bottomRowSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { 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 fresh row at
|
||||
* the bottom of the target section (`bottomRowSlot`). Persisted as one atomic patch.
|
||||
* 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.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,10 +52,12 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// 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 }];
|
||||
// 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 }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -66,9 +65,6 @@ 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}
|
||||
@@ -81,7 +77,6 @@ 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}
|
||||
>
|
||||
@@ -92,7 +87,6 @@ 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,7 +3,6 @@ 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 = {
|
||||
@@ -32,7 +31,6 @@ function SectionGridItem({
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
useScrollIntoView(panelId, containerRef);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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,8 +11,27 @@ import {
|
||||
reorderLayoutsOp,
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
|
||||
import { getSectionStableId } from '../../../utils';
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
@@ -33,7 +52,6 @@ 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> => {
|
||||
@@ -41,24 +59,22 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const isFirstSection = !layouts || layouts.length === 0;
|
||||
const op = isFirstSection
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
// 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));
|
||||
scrollToNewSection(prevSectionCount);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, patchAsync, showErrorModal, setScrollTargetId],
|
||||
[layouts, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
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"}');
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
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,18 +1,69 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsJson, 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 { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
import { doAllQueryVariablesHaveValues } from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
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 };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
@@ -27,21 +78,25 @@ interface UseVariableSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const { variables, fetchContext } = useSeedVariableSelection(dashboard);
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
|
||||
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(
|
||||
@@ -57,11 +112,48 @@ export function useVariableSelection(
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [, setUrlValues] = useQueryState(
|
||||
const [urlValues, 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(
|
||||
@@ -71,6 +163,10 @@ 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),
|
||||
);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,12 +4,9 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
bottomRowSlot,
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -146,77 +143,6 @@ 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,10 +64,4 @@ 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,12 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import { generatePath } 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;
|
||||
@@ -26,7 +25,6 @@ interface UseCreatePanelResult {
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
@@ -50,11 +48,9 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(
|
||||
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
|
||||
);
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex, search],
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import { generatePath } 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 `?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.
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
})}${withVariablesSearch('', search)}`,
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, search],
|
||||
[safeNavigate, dashboardId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,13 @@ import { useDashboardStore } from '../store/useDashboardStore';
|
||||
* panel queries and triggers a refetch with the new values.
|
||||
*/
|
||||
export function useResolvedVariables(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): void {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const definitions = useMemo(
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
@@ -172,43 +172,9 @@ const GRID_COLS = 12;
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
@@ -219,25 +185,19 @@ 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);
|
||||
|
||||
// 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))) {
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return bottomRowSlot(items);
|
||||
return { x: 0, y: bottom };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
export interface AggregationView {
|
||||
interface AggregationView {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
}
|
||||
@@ -108,25 +108,8 @@ function getColName(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
* Stable row-data key (port of V1 `getColId`). Multi-aggregation queries need
|
||||
* `queryName.expression` so value columns don't collide.
|
||||
*/
|
||||
function getColId(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
@@ -146,11 +129,12 @@ function getColId(
|
||||
return col.name;
|
||||
}
|
||||
|
||||
return getAggregationColumnKey(
|
||||
queryName,
|
||||
aggregationsPerQuery[queryName],
|
||||
col.aggregationIndex ?? 0,
|
||||
);
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
return queryName;
|
||||
}
|
||||
|
||||
export interface PrepareScalarTablesArgs {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
generatePath,
|
||||
Redirect,
|
||||
@@ -7,15 +7,13 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
@@ -24,9 +22,7 @@ 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 { buildNewPanelSeed } from './newPanelSeed';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
@@ -45,45 +41,35 @@ 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, refetch } =
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
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, 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);
|
||||
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
// An explorer "Add to Dashboard" export rides the query in `compositeQuery` (V1
|
||||
// parity). Captured once at mount: the editor rewrites `compositeQuery` in the URL
|
||||
// as the user edits, and re-reading it would churn the draft (its reset target and
|
||||
// dirty baseline live in the initially-loaded panel).
|
||||
const exportCompositeQuery = useGetCompositeQueryParam();
|
||||
const exportCompositeQueryRef = useRef(exportCompositeQuery);
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
// kind rather than looking one up (seeded from the exported query when present).
|
||||
// Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
// `kind` may be coerced from `newKind` (e.g. a ClickHouse query into List).
|
||||
const { kind, pluginSpec, queries } = buildNewPanelSeed(
|
||||
newKind,
|
||||
buildPluginSpec(getPanelDefinition(newKind).sections),
|
||||
buildDefaultQueries(newKind),
|
||||
exportCompositeQueryRef.current,
|
||||
);
|
||||
return createDefaultPanel(kind, pluginSpec, queries);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
@@ -98,11 +84,16 @@ 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 })}${withVariablesSearch(
|
||||
'',
|
||||
search,
|
||||
)}`,
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${
|
||||
query ? `?${query}` : ''
|
||||
}`,
|
||||
);
|
||||
}, [safeNavigate, dashboardId, search]);
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { buildNewPanelSeed } from './newPanelSeed';
|
||||
|
||||
jest.mock('../DashboardContainer/queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/utils/buildDefaultQueries', () => ({
|
||||
buildDefaultQueries: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/utils/buildPluginSpec', () => ({
|
||||
buildPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
|
||||
isQueryTypeSupported: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockToPerses = toPerses as jest.Mock;
|
||||
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
|
||||
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
|
||||
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
|
||||
const mockIsQueryTypeSupported = isQueryTypeSupported as jest.Mock;
|
||||
|
||||
const DEFAULT_QUERIES = [{ kind: 'default' }];
|
||||
const CONVERTED_QUERIES = [{ kind: 'converted' }];
|
||||
const BASE_SPEC = { visualization: { foo: 1 } };
|
||||
|
||||
const withUnit = [{ kind: SectionKind.Formatting, controls: { unit: true } }];
|
||||
const withoutUnit = [
|
||||
{ kind: SectionKind.Formatting, controls: { decimals: true } },
|
||||
];
|
||||
|
||||
const q = (extra: Partial<Query> = {}): Query =>
|
||||
({ queryType: 'builder', ...extra }) as unknown as Query;
|
||||
|
||||
describe('buildNewPanelSeed', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
|
||||
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
|
||||
mockIsQueryTypeSupported.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('uses the kind default seed when there is no exported query', () => {
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', null);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(DEFAULT_QUERIES);
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
expect(mockToPerses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converts the exported query into perses queries', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q());
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
'signoz/ListPanel',
|
||||
q({ queryType: EQueryType.CLICKHOUSE }),
|
||||
);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TablePanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
});
|
||||
|
||||
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
|
||||
mockIsQueryTypeSupported.mockReturnValue(false);
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
|
||||
const seed = buildNewPanelSeed(
|
||||
'signoz/ListPanel',
|
||||
q({ queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
|
||||
expect(seed.queries).toBe(CONVERTED_QUERIES);
|
||||
});
|
||||
|
||||
it('falls back to the default seed when conversion yields nothing runnable', () => {
|
||||
mockToPerses.mockReturnValue([]);
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/ListPanel', q());
|
||||
|
||||
expect(seed.queries).toBe(DEFAULT_QUERIES);
|
||||
});
|
||||
|
||||
it('preserves the exported unit when the kind supports a unit', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q({ unit: 'ms' }));
|
||||
|
||||
expect(seed.pluginSpec).toStrictEqual({
|
||||
...BASE_SPEC,
|
||||
formatting: { unit: 'ms' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the unit for a kind without a unit control', () => {
|
||||
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: withoutUnit });
|
||||
|
||||
const seed = buildNewPanelSeed('signoz/TablePanel', q({ unit: 'ms' }));
|
||||
|
||||
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
|
||||
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import {
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
|
||||
|
||||
interface NewPanelSeed {
|
||||
/** Effective kind to create — may differ from the requested one (see below). */
|
||||
kind: PanelKind;
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
pluginSpec: SeededPluginSpec;
|
||||
}
|
||||
|
||||
/** Whether a kind's Formatting section exposes a `unit` control (Table/List don't). */
|
||||
function kindSupportsUnit(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).sections.some(
|
||||
(section) =>
|
||||
section.kind === SectionKind.Formatting &&
|
||||
'controls' in section &&
|
||||
section.controls.unit === true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective kind for an export: the requested kind, or — when it can't hold the query's
|
||||
* language (only List, which is builder-only) — a compatible one so the query isn't
|
||||
* dropped: PromQL → TimeSeries, ClickHouse → Table.
|
||||
*/
|
||||
function resolveSeededPanelKind(
|
||||
requestedKind: PanelKind,
|
||||
compositeQuery: Query | null,
|
||||
): PanelKind {
|
||||
if (
|
||||
!compositeQuery ||
|
||||
isQueryTypeSupported(requestedKind, compositeQuery.queryType)
|
||||
) {
|
||||
return requestedKind;
|
||||
}
|
||||
return compositeQuery.queryType === EQueryType.PROM
|
||||
? 'signoz/TimeSeriesPanel'
|
||||
: 'signoz/TablePanel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed for a new panel from the explorer "Add to Dashboard" export: resolve the effective
|
||||
* kind, convert the exported `compositeQuery` to perses queries, and carry a unit-bearing
|
||||
* kind's explorer `unit`. Falls back to the kind's default seed when there's no query or
|
||||
* conversion yields nothing runnable.
|
||||
*/
|
||||
export function buildNewPanelSeed(
|
||||
requestedKind: PanelKind,
|
||||
compositeQuery: Query | null,
|
||||
): NewPanelSeed {
|
||||
const kind = resolveSeededPanelKind(requestedKind, compositeQuery);
|
||||
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
|
||||
|
||||
if (!compositeQuery) {
|
||||
return { kind, queries: buildDefaultQueries(kind), pluginSpec };
|
||||
}
|
||||
|
||||
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
|
||||
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
|
||||
|
||||
if (compositeQuery.unit && kindSupportsUnit(kind)) {
|
||||
return {
|
||||
kind,
|
||||
queries,
|
||||
pluginSpec: {
|
||||
...pluginSpec,
|
||||
formatting: { ...pluginSpec.formatting, unit: compositeQuery.unit },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { kind, queries, pluginSpec };
|
||||
}
|
||||
@@ -759,19 +759,18 @@ describe('TracesExplorer -', () => {
|
||||
expect(createDashboardBtn).toBeInTheDocument();
|
||||
fireEvent.click(createDashboardBtn);
|
||||
|
||||
await expect(screen.findByText('Export Panel')).resolves.toBeInTheDocument();
|
||||
const createDashboardModal = document.querySelector(
|
||||
'.ant-modal-content',
|
||||
) as HTMLElement;
|
||||
const createDashboardModal = (await screen.findByRole(
|
||||
'dialog',
|
||||
)) as HTMLElement;
|
||||
expect(createDashboardModal).toBeInTheDocument();
|
||||
|
||||
// assert modal content
|
||||
expect(
|
||||
within(createDashboardModal).getByText('Select Dashboard'),
|
||||
within(createDashboardModal).getByTestId('export-panel-new-dashboard'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(createDashboardModal).getByText('New Dashboard'),
|
||||
within(createDashboardModal).getByTestId('export-panel-export'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/config
|
||||
import QuerySection from 'container/TracesExplorer/QuerySection';
|
||||
import TableView from 'container/TracesExplorer/TableView';
|
||||
import TracesView from 'container/TracesExplorer/TracesView';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -46,7 +47,6 @@ import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
@@ -144,6 +144,7 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
@@ -231,7 +232,7 @@ function TracesExplorer(): JSX.Element {
|
||||
dashboardName: dashboard?.data?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
@@ -240,7 +241,13 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, panelType, safeNavigate, options],
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
@@ -48,25 +48,6 @@ 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{
|
||||
|
||||
@@ -49,18 +49,17 @@ 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.CheckTypeKubeContainers: kubeContainersSpec,
|
||||
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,
|
||||
}
|
||||
|
||||
// Per-type specs. Every metric and attribute is spelled out in its own spec
|
||||
@@ -101,42 +100,6 @@ 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{
|
||||
{
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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,30 +94,6 @@ 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,61 +132,3 @@ 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,110 +357,6 @@ 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,11 +8,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
containerNameAttrKey = "k8s.container.name"
|
||||
containerStatusReasonAttrKey = "k8s.container.status.reason"
|
||||
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
podStatusReasonMetricName = "k8s.pod.status_reason"
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
podStatusReasonMetricName = "k8s.pod.status_reason"
|
||||
containerStatusReasonMetricName = "k8s.container.status.reason"
|
||||
containerRestartsMetricName = "k8s.container.restarts"
|
||||
)
|
||||
|
||||
// podStatusMetricNamesList are the metrics required to derive the kubectl-style
|
||||
@@ -41,8 +45,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,7 +12,6 @@ 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)
|
||||
@@ -28,7 +27,6 @@ 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)
|
||||
|
||||
@@ -9,18 +9,17 @@ 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")}
|
||||
CheckTypeKubeContainers = CheckType{valuer.NewString("kube_containers")}
|
||||
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")}
|
||||
)
|
||||
|
||||
func (CheckType) Enum() []any {
|
||||
@@ -36,7 +35,6 @@ func (CheckType) Enum() []any {
|
||||
CheckTypeNamespaces,
|
||||
CheckTypeClusters,
|
||||
CheckTypeVolumes,
|
||||
CheckTypeKubeContainers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +50,6 @@ var ValidCheckTypes = []CheckType{
|
||||
CheckTypeNamespaces,
|
||||
CheckTypeClusters,
|
||||
CheckTypeVolumes,
|
||||
CheckTypeKubeContainers,
|
||||
}
|
||||
|
||||
// CheckComponentType tags each AssociatedComponent as either a receiver or a processor.
|
||||
|
||||
@@ -84,11 +84,6 @@ 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 {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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,
|
||||
}
|
||||
5
tests/fixtures/http.py
vendored
5
tests/fixtures/http.py
vendored
@@ -17,8 +17,6 @@ from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ZEUS_NETWORK_ALIAS = "signoz-zeus"
|
||||
|
||||
|
||||
@pytest.fixture(name="zeus", scope="package")
|
||||
def zeus(
|
||||
@@ -33,7 +31,6 @@ def zeus(
|
||||
def create() -> types.TestContainerDocker:
|
||||
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(ZEUS_NETWORK_ALIAS)
|
||||
container.start()
|
||||
|
||||
return types.TestContainerDocker(
|
||||
@@ -45,7 +42,7 @@ def zeus(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
|
||||
136
tests/fixtures/signoz.py
vendored
136
tests/fixtures/signoz.py
vendored
@@ -1,19 +1,14 @@
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
from testcontainers.core.image import DockerImage
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
@@ -21,110 +16,6 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigNozImageBuild:
|
||||
process: subprocess.Popen
|
||||
command: list[str]
|
||||
cache_path: Path | None = None
|
||||
next_cache_path: Path | None = None
|
||||
reader: threading.Thread | None = None
|
||||
|
||||
|
||||
def _stream_build_output(pipe, log) -> None:
|
||||
try:
|
||||
for line in iter(pipe.readline, ""):
|
||||
log.info("buildx: %s", line.rstrip())
|
||||
finally:
|
||||
pipe.close()
|
||||
|
||||
|
||||
def start_signoz_image_build(pytestconfig: pytest.Config, dockerfile_path: str, arch: str, zeus_url: str) -> SigNozImageBuild:
|
||||
root = pytestconfig.rootpath.parent
|
||||
command = [
|
||||
"docker",
|
||||
"buildx",
|
||||
"build",
|
||||
"--load",
|
||||
"--progress",
|
||||
"plain",
|
||||
"--tag",
|
||||
"signoz:integration",
|
||||
"--file",
|
||||
dockerfile_path,
|
||||
"--build-arg",
|
||||
f"TARGETARCH={arch}",
|
||||
"--build-arg",
|
||||
f"ZEUSURL={zeus_url}",
|
||||
str(root),
|
||||
]
|
||||
|
||||
cache_path = None
|
||||
next_cache_path = None
|
||||
if os.environ.get("ACTIONS_RUNTIME_TOKEN"):
|
||||
# Running in GitHub Actions — use BuildKit's native GHA cache backend.
|
||||
# Avoids the local-write races and partial exports seen with type=local.
|
||||
scope = os.environ.get("SIGNOZ_BUILDX_GHA_SCOPE", "signoz-integration")
|
||||
command.extend(["--cache-from", f"type=gha,scope={scope}"])
|
||||
command.extend(["--cache-to", f"type=gha,scope={scope},mode=max"])
|
||||
elif build_cache_dir := os.environ.get("SIGNOZ_INTEGRATION_BUILD_CACHE_DIR"):
|
||||
# Local cache for developer machines / non-GHA CI.
|
||||
cache_path = Path(build_cache_dir)
|
||||
next_cache_path = Path(f"{build_cache_dir}-next")
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(next_cache_path, ignore_errors=True)
|
||||
|
||||
if cache_path.exists():
|
||||
command.extend(["--cache-from", f"type=local,src={cache_path}"])
|
||||
command.extend(["--cache-to", f"type=local,dest={next_cache_path},mode=max"])
|
||||
|
||||
logger.info("Building SigNoz integration image with %s", " ".join(command))
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
reader = threading.Thread(target=_stream_build_output, args=(process.stdout, logger), daemon=True)
|
||||
reader.start()
|
||||
|
||||
return SigNozImageBuild(
|
||||
process=process,
|
||||
command=command,
|
||||
cache_path=cache_path,
|
||||
next_cache_path=next_cache_path,
|
||||
reader=reader,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_signoz_image_build(build: SigNozImageBuild) -> None:
|
||||
returncode = build.process.wait()
|
||||
if build.reader is not None:
|
||||
build.reader.join(timeout=5)
|
||||
if returncode != 0:
|
||||
raise subprocess.CalledProcessError(returncode, build.command)
|
||||
|
||||
if build.cache_path and build.next_cache_path and build.next_cache_path.exists():
|
||||
shutil.rmtree(build.cache_path, ignore_errors=True)
|
||||
shutil.move(build.next_cache_path, build.cache_path)
|
||||
|
||||
|
||||
def stop_signoz_image_build(build: SigNozImageBuild) -> None:
|
||||
if build.process.poll() is not None:
|
||||
return
|
||||
|
||||
build.process.terminate()
|
||||
try:
|
||||
build.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
build.process.kill()
|
||||
build.process.wait()
|
||||
|
||||
if build.reader is not None:
|
||||
build.reader.join(timeout=5)
|
||||
|
||||
|
||||
def create_signoz(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
@@ -142,6 +33,9 @@ def create_signoz(
|
||||
"""
|
||||
|
||||
def create() -> types.SigNoz:
|
||||
# Run the migrations for clickhouse
|
||||
request.getfixturevalue("migrator")
|
||||
|
||||
# Get the no-web flag
|
||||
with_web = pytestconfig.getoption("--with-web")
|
||||
|
||||
@@ -154,15 +48,19 @@ def create_signoz(
|
||||
if with_web:
|
||||
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
|
||||
|
||||
# The SigNoz image build does not depend on ClickHouse migrations, so
|
||||
# build it while the migrator container runs.
|
||||
image_build = start_signoz_image_build(pytestconfig, dockerfile_path, arch, zeus.container_configs["8080"].base())
|
||||
try:
|
||||
request.getfixturevalue("migrator")
|
||||
wait_for_signoz_image_build(image_build)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
stop_signoz_image_build(image_build)
|
||||
raise
|
||||
# Docker build context is the repo root — one up from pytest's
|
||||
# rootdir (tests/).
|
||||
self = DockerImage(
|
||||
path=str(pytestconfig.rootpath.parent),
|
||||
dockerfile_path=dockerfile_path,
|
||||
tag="signoz:integration",
|
||||
buildargs={
|
||||
"TARGETARCH": arch,
|
||||
"ZEUSURL": zeus.container_configs["8080"].base(),
|
||||
},
|
||||
)
|
||||
|
||||
self.build()
|
||||
|
||||
env = (
|
||||
{
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{"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}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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}
|
||||
@@ -1,16 +0,0 @@
|
||||
{"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}
|
||||
@@ -1,12 +0,0 @@
|
||||
{"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}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user