Compare commits

...

7 Commits

Author SHA1 Message Date
vikrantgupta25
baa5da538f chore(sql): remove the redundant flag from integration tests 2026-07-09 18:32:38 +05:30
vikrantgupta25
abe3a0aa88 chore(sql): enable txlock immediate by default 2026-07-09 18:26:04 +05:30
Nikhil Mantri
da250bfd3e fix: commented out container metrics to surface k8s.pod.start_time (#12042) 2026-07-09 12:09:53 +00:00
Nikhil Mantri
67007e2902 feat(infra-monitoring): Kubernetes Container Monitoring List API (#11946)
* chore: added types for containers

* chore: added querier query and constants

* chore: added helper queries

* chore: added wiring

* chore: added open api spec

* chore: endpoint and variable rename

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

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

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

* chore: corrected metrics list for metadata lookup

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

* chore: containers query modified

* chore: added integration tests

* chore: integration tests

* chore: goroutines in container monitoring

* chore: constants deduplication

* chore: inlined requests.Post call for this PR instead of wrapping in a function
2026-07-09 11:42:24 +00:00
Ashwin Bhatkal
ff3ec133b4 fix(dashboard-v2): stop false 'updated elsewhere' prompts on your own edits (#12035)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboard-v2): close the 'updated elsewhere' dialog on reload

Reload set dismissedAt to null and relied on the refetched loaded copy's updatedAt
converging with the freshness query's serverUpdatedAt to hide the prompt. Those are two
separate queries and the convergence is racy, so the dialog often stayed open across
repeated reloads until Dismiss was clicked. Make Reload acknowledge the current version
(like Dismiss) and then refetch, so it closes on click; a genuinely newer server version
still re-prompts.

* fix(dashboard-v2): only prompt when the server copy is strictly newer

The stale-check compared the loaded and freshness copies of updatedAt with !==. Your own
edits advance the loaded copy immediately (the optimistic patch writes the PATCH response's
new updatedAt into the render cache), while the freshness query only re-fetches on window
focus. So right after creating a panel, renaming, or editing a variable, loaded jumped ahead
while freshness lagged, and !== flagged your own change as 'updated elsewhere' — a false
prompt that persisted across in-app navigation until a real window-focus refetch.

Compare with a strictly-newer (>) check so the prompt only fires when the server has a
version we have not loaded. A genuine external change still trips it; our own edits (loaded
ahead of the lagging freshness copy) no longer do.

* fix(dashboard-v2): probe freshness on visibilitychange, not refetchOnWindowFocus

The freshness check used a separate react-query query with initialData + refetchOnMount:false
so the first load made no extra request. But a query that only holds seeded data and never
fetched isn't reliably eligible for react-query's focus refetch, so on tab return no request
fired and external changes went undetected.

Drop the separate query and probe the server's updatedAt explicitly on 'visibilitychange'
(guarded to the visible transition): zero requests on first load, exactly one per tab return,
no dependence on react-query focus heuristics or staleTime bookkeeping. Comparison and
reload/dismiss behaviour are unchanged (strictly-newer; reload = dismiss + refetch).

* fix(dashboard-v2): probe on window focus too, and gate stale prompt on spec content

Two fixes to the freshness probe:

- Also listen to window 'focus', not just document 'visibilitychange'. Switching to
  another app (e.g. the editor) and back keeps the browser tab 'visible', so
  visibilitychange never fires and no probe ran — the reported 'no call when coming
  back to the page'. focus covers that; visibilitychange still covers tab switches.

- Prompt only when the server's spec actually differs (deep-equal), gated by
  strictly-newer. updatedAt is bumped by metadata-only writes like lock/unlock that
  don't change what the viewer sees; keying on spec makes those a non-event without
  needing the (void) lock/unlock call to return the new updatedAt. Hook now takes the
  loaded dashboard so it has both updatedAt and spec to compare.

* docs(dashboard-v2): clarify stale-check probes on both tab switch and app focus

* docs(dashboard-v2): trim useDashboardStaleCheck comments

* fix(dashboard-v2): detect external tag and lock changes too

Widen the stale-check content comparison from spec-only to spec + tags + lock state, so an
external tag edit or lock/unlock by another user surfaces the reload prompt. Own actions still
don't trip it: the render cache advances with them (optimistic patch / cache sync), so loaded
matches server.
2026-07-09 04:32:00 +00:00
Ashwin Bhatkal
0ea5399c15 feat(dashboard-v2): bug bash fixes II (list, dashboard page, create modal) (#12040)
* fix(dashboard-v2): source Home recent dashboards from the v2 list under the flag

* fix(dashboard-v2): keep same-key:value tags from collapsing to one word in the list

* fix(dashboard-v2): commit the inline dashboard-title edit on outside click

* fix(dashboard-v2): cap variable multi-select pills so the bar doesn't overflow

* fix(dashboard-v2): persist the variables-bar expand state and show "Less" when it loads expanded

* feat(dashboard-v2): reusable dashboard image picker for details + create modal

Extract the icon/image picker into a shared DashboardImagePicker used by both the dashboard settings form and the create-dashboard modal. Patch `/image` with add (not replace) when the field is absent, and lift the inline dropdown above the create-modal form fields.

* feat(dashboard-v2): simplify the create-modal templates tab to browse-and-request

Drop the placeholder gallery (search + category list + preview) for a clean, centered browse-and-request block: link out to the published template library, and let cloud users request a template we haven't built yet.

* fix(dashboard-v2): contain Escape/Enter and pass Cmd+Enter through the tag editor

Escape/Enter no longer bubble to close the host drawer/modal; Cmd/Ctrl+Enter passes through so a host form can submit on it (plain Enter still adds/commits a tag).

* feat(dashboard-v2): add/edit a dashboard's tags from the list

Add an "Add Tags"/"Edit Tags" kebab action (labelled by whether the dashboard has tags) opening the shared key:value editor, saving via an `add /tags` patch, with Cmd/Ctrl+Enter save. Gated on edit permission, disabled while locked. Also guard row navigation so clicks inside portaled overlays (menu/modals) don't open the dashboard.
2026-07-09 04:27:43 +00:00
Abhi kumar
f7aa3cee9c fix(dashboard-v2): view-modal, pie chart, and panel fixes (#12033)
* fix(dashboard-v2): tint chart-legend checkbox with its series color

The @signozhq/ui Checkbox declares --checkbox-checked-background on the
element itself via its data-color rule, so a value set on an ancestor was
shadowed and the per-series tint never applied. Feed the color through a
private --series-color var and override the vars on the element with a
selector that out-specifies the library rule.

* fix(dashboard-v2): make antd popups usable inside the view panel modal

The editor's antd Selects/pickers portal to document.body, outside the
modal Radix dialog, where Radix disables pointer events and traps focus, so
their menus opened but couldn't be clicked. Wrap the modal body in an antd
ConfigProvider whose getPopupContainer points at the dialog content, so all
antd popups render inside the interactive, focus-trapped layer.

* feat(dashboard-v2): add the list columns editor and pagination loader to the view modal

Surface the List panel's columns editor in the View modal (as in the full
editor) by exposing setSpec from useViewPanelMode and wiring it into the
query-builder footer. Its add-column combobox portals to body, so inside
the modal it opened behind the dialog — lift its z-index above the
query-builder popups so it stacks on top.

Also thread isPreviousData into PreviewPane so the List panel's page-change
loader shows during pagination, matching the full editor and dashboard.

* fix(dashboard-v2): measure chart dimensions before first paint

useResizeObserver seeded its size to 0 and only reported real dimensions
after paint via its debounced observer, so charts rendered once at 0 then
jumped — most visibly the pie legend, which rendered left-aligned then
re-centered once it learned it fit on one row. Add a useLayoutEffect that
measures the element synchronously before paint. In jsdom clientWidth is 0,
so tests are unaffected.

* feat(dashboard-v2): support per-slice color overrides for pie charts

Enable the legend colors control for pie charts. The control is fed by
useLegendSeries, which only handled time-series data, so pie returned no
series. Resolve pie legend series from the same scalar slices the renderer
draws (without overrides, for default colors) so the color keys match what
the chart looks up.

Extract the series resolution into utils/legendSeries.ts (a shared dedupe
primitive plus a pie and a time-series resolver) and reduce the hook to a
kind switch that returns none for kinds without a colors control.

* feat(dashboard-v2): offer dashboard root and every section in the move menu

Rework the "Move to section" submenu to list the dashboard root plus every
titled section (minus the panel's own), instead of only titled sections with
a separate "move out of section" entry. Hide it entirely when there is
nowhere to move.

* fix(dashboard-v2): hide the empty panel header in editor preview

When a panel has no title, description, or status and the actions menu is
suppressed (editor preview), render nothing instead of an empty header bar.
Add a min-height so the header keeps a stable size when it does render.

* fix(dashboard-v2): lazy-load panels per-panel instead of per-section

Move the viewport intersection observer down from Section to a new
SectionGridItem wrapper so each panel gates its own fetch. A board with
many panels now only queries the ones actually on screen, instead of
loading every panel in any section that scrolls into view.

* fix(dashboard-v2): hide "Extend time range" for panels with a fixed time preference

A panel locked to a non-global timePreference queries a fixed relative span
anchored to the dashboard end, so widening the ambient window can't surface
data for it — the "Extend time range" CTA was a no-op. NoData now takes the
panel and, via panelHasFixedTimePreference, drops the global extender for such
panels (only Retry remains). The View modal's local extender still wins.

* refactor(dashboard-v2): read panel time preference via shared helper

Panel.tsx read visualization.timePreference through an inline cast; replace it
with getPanelTimePreference so the header pill and NoData's extend gate share
one reader.

* feat(dashboard-v2): support per-series legend colors for bar and histogram panels

Route BarChartPanel and HistogramPanel through the time-series legend path in
useLegendSeries and enable the Legend `colors` control in both kinds' sections,
extending the per-series color overrides to these kinds.

* feat(dashboard-v2): pin the query-type tabs to the top of the editor scroll area

Make the query-type tab nav + Run Query button sticky so they stay visible
while the query body scrolls underneath; move the top padding onto the nav so
it keeps its spacing at rest and when pinned.
2026-07-09 04:23:37 +00:00
92 changed files with 4007 additions and 566 deletions

View File

@@ -129,7 +129,7 @@ sqlstore:
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: deferred
transaction_mode: immediate
##################### APIServer #####################
apiserver:

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,9 @@ function TagKeyValueInput({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
// (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
commit();
}
@@ -93,11 +95,17 @@ function TagKeyValueInput({
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
// form (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
commitEdit();
} else if (e.key === 'Escape') {
// Contain Escape so it cancels the inline edit instead of bubbling up and
// closing the host drawer/modal.
e.preventDefault();
e.stopPropagation();
cancelEdit();
}
};

View File

@@ -0,0 +1,4 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -15,12 +16,11 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
'--series-color': color,
} as CSSProperties;
return (
<span style={colorStyle}>
<span className={styles.wrapper} style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -1,15 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
tags: string[];
}
export default function Dashboards({
onUpdateChecklistDoneItem,
loadingUserPreferences,
@@ -26,33 +38,58 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
// Fetch Dashboards
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const {
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useGetAllDashboard();
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
useEffect(() => {
if (!dashboardsList) {
return;
}
const sortedDashboards = dashboardsList.data.sort((a, b) => {
const aUpdateAt = new Date(a.updatedAt).getTime();
const bUpdateAt = new Date(b.updatedAt).getTime();
return bUpdateAt - aUpdateAt;
});
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
}
setSortedDashboards(sortedDashboards.slice(0, 5));
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -113,7 +150,7 @@ export default function Dashboards({
event.stopPropagation();
logEvent('Homepage: Dashboard clicked', {
dashboardId: dashboard.id,
dashboardName: dashboard.data.title,
dashboardName: dashboard.title,
});
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
@@ -143,12 +180,12 @@ export default function Dashboards({
/>
<div className="alert-rule-item-name home-data-item-name">
{dashboard.data.title}
{dashboard.title}
</div>
</div>
<div className="alert-rule-item-description home-data-item-tag">
{dashboard.data.tags?.map((tag) => (
{dashboard.tags.map((tag) => (
<Badge color="sienna" variant="outline" key={tag}>
{tag}
</Badge>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import debounce from 'lodash-es/debounce';
export type Dimensions = {
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
height: ref.current?.clientHeight || 0,
});
// Measure before paint so the first frame has real dimensions, not 0 (the
// debounced observer below only catches up after paint → layout jump).
useLayoutEffect(() => {
const node = ref.current;
if (node) {
setSize({ width: node.clientWidth, height: node.clientHeight });
}
}, [ref]);
useEffect(() => {
const handleResize = debounce(
(entries: ResizeObserverEntry[]) => {

View File

@@ -1,4 +1,4 @@
import { KeyboardEvent } from 'react';
import { type FocusEvent, KeyboardEvent } from 'react';
import {
Check,
Globe,
@@ -90,12 +90,20 @@ function DashboardInfo({
}
};
// Clicking outside the editor commits, matching the input's Enter behaviour.
// Guard against blurs that move focus to the Save/Cancel buttons within it.
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
onCommit();
}
};
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}

View File

@@ -0,0 +1,32 @@
.trigger {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.options {
min-width: min-content;
}
.item {
width: min-content;
span {
vertical-align: middle;
}
}
.image {
height: 16px;
width: 16px;
}

View File

@@ -0,0 +1,43 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import cx from 'classnames';
import { Base64Icons } from '../utils';
import styles from './DashboardImagePicker.module.scss';
interface Props {
// The selected image — one of the base64 icon data-URIs.
image: string;
onChange: (value: string) => void;
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
// a name input); this component owns size / background / icon-only styling.
triggerClassName?: string;
}
// Icon picker shared by the dashboard-details settings and the create-dashboard
// modal so both choose from the same `Base64Icons` set.
function DashboardImagePicker({
image,
onChange,
triggerClassName,
}: Props): JSX.Element {
return (
<Select value={image} onChange={(value): void => onChange(value as string)}>
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
<SelectContent className={styles.options} withPortal={false}>
{Base64Icons.map((icon) => (
<SelectItem key={icon} value={icon} className={styles.item}>
<img src={icon} alt="dashboard-icon" className={styles.image} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DashboardImagePicker;

View File

@@ -25,38 +25,10 @@
}
}
// The image-picker owns its size/background; here we only round the left edge so
// it joins flush with the name input to its right.
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {

View File

@@ -1,17 +1,11 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
@@ -43,30 +37,11 @@ function DashboardInfoForm({
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<DashboardImagePicker
image={image}
onChange={onImageChange}
triggerClassName={styles.dashboardImageInput}
/>
<Input
testId="dashboard-name"

View File

@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
);
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
// `replace` fails when the image doesn't exist yet, so add it when the
// dashboard has none (`add` creates or replaces the member). Key off the
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
ops.push(
op(
dashboard.image
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/image',
updatedImage,
),
);
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
description,
updatedImage,
image,
dashboard.image,
updatedTags,
tagsAsStrings,
]);

View File

@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -7,4 +7,9 @@
.dropdown {
width: 260px;
/* The dropdown portals to body; lift it above the query builder's antd
* popups (1050) so it stays clickable, incl. inside the View modal.
* TODO: remove after the antd -> @signozhq/ui migration
**/
z-index: 1100;
}

View File

@@ -63,7 +63,7 @@ function AddColumnDropdown({
<Plus size={16} />
</Button>
</ComboboxTrigger>
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}

View File

@@ -10,7 +10,9 @@
}
.scrollArea {
padding: 12px;
// Top padding lives on the sticky tab nav instead, so the nav keeps its
// breathing room both at rest and when pinned to the top.
padding: 0 12px 12px 12px;
}
.tabsContainer {
@@ -24,6 +26,15 @@
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
// Pin the query-type tabs + Run Query button to the top of the
// `.container` scroll area while the query body scrolls underneath.
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
position: sticky;
top: 0px;
z-index: 1100;
padding-top: 12px;
background-color: var(--l1-background);
&::before {
border-color: var(--l2-border);
}

View File

@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -29,18 +30,36 @@ jest.mock(
() => ({
flattenTimeSeries: jest.fn(),
getTimeSeriesResults: jest.fn(() => []),
getScalarResults: jest.fn(() => []),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn(() => []) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
() => ({ preparePieData: jest.fn(() => []) }),
);
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const PIE_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
]);
});
it('resolves histogram panels via the time-series path', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
{ label: 'b', defaultColor: 'color:b' },
]);
// The pie path must not run for a histogram panel.
expect(mockPreparePie).not.toHaveBeenCalled();
});
it('resolves pie panels from their scalar slices, deduped by label', () => {
mockPreparePie.mockReturnValue([
{ label: 'x', color: 'c1' },
{ label: 'y', color: 'c2' },
{ label: 'x', color: 'c1' },
]);
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'x', defaultColor: 'c1' },
{ label: 'y', defaultColor: 'c2' },
]);
// The time-series path must not run for a pie panel.
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -1,30 +1,19 @@
import { useMemo } from 'react';
import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
import {
type LegendSeries,
resolvePieLegendSeries,
resolveTimeSeriesLegendSeries,
} from '../utils/legendSeries';
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
* order-preserving; empty until data arrives or for kinds without flat time-series data.
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
* legend-colors control can key overrides by the exact labels the chart draws. Only the
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
* Series from its flat series); every other kind returns none.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
@@ -33,27 +22,15 @@ export function useLegendSeries(
const isDarkMode = useIsDarkMode();
return useMemo(() => {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const series = flattenTimeSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel.spec.queries);
const byLabel = new Map<string, string>();
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
if (label && !byLabel.has(label)) {
byLabel.set(label, generateColor(label, palette));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
switch (panel.spec.plugin.kind) {
case 'signoz/PieChartPanel':
return resolvePieLegendSeries(data, isDarkMode);
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/HistogramPanel':
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
default:
return [];
}
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
}

View File

@@ -0,0 +1,96 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
flattenTimeSeries,
getScalarResults,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
/**
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
* label never resolves a second color.
*/
function buildLegendSeries(
labels: readonly string[],
colorFor: (label: string, index: number) => string,
): LegendSeries[] {
const byLabel = new Map<string, string>();
labels.forEach((label, index) => {
if (label && !byLabel.has(label)) {
byLabel.set(label, colorFor(label, index));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}
/**
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
* draws (without overrides, so their colors are the defaults) so the color control keys
* overrides by the same labels the chart does.
*/
export function resolvePieLegendSeries(
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const slices = preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}),
isDarkMode,
});
return buildLegendSeries(
slices.map((slice) => slice.label),
(_, index) => slices[index].color,
);
}
/**
* Time-series kinds: resolve each flattened series' label the way the renderer does
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
*/
export function resolveTimeSeriesLegendSeries(
queries: PanelQueries,
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const builderQueries = getBuilderQueries(queries);
const series = flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap,
);
return buildLegendSeries(
series.map((s) =>
resolveSeriesLabelV5(
s,
builderQueries,
getLabelName(s.labels, s.queryName, s.legend),
),
),
(label) => generateColor(label, palette),
);
}

View File

@@ -1,5 +1,7 @@
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
import {
selectViewPanelExtendWindow,
useViewPanelStore,
@@ -15,6 +17,8 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -29,19 +33,30 @@ function NoData({
description = 'Nothing in the selected window. Try widening the range.',
isFetching = false,
onRetry,
panel,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
const globalExtend = useExtendTimeWindow();
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
if (isFetching) {
return <PanelLoader />;
}
const extendAction: PanelMessageAction | undefined =
canExtend && actionLabel
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
activeExtend?.canExtend && activeExtend.actionLabel
? {
label: activeExtend.actionLabel,
onClick: activeExtend.extend,
icon: <CalendarRange size={14} />,
}
: undefined;
const retryAction: PanelMessageAction | undefined = onRetry

View File

@@ -1,3 +1,7 @@
import {
DashboardtypesTimePreferenceDTO,
type DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen } from '@testing-library/react';
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
@@ -24,6 +28,15 @@ function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
};
}
/** Minimal panel whose plugin spec carries the given time preference. */
function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: { plugin: { spec: { visualization: { timePreference } } } },
} as unknown as DashboardtypesPanelDTO;
}
describe('NoData', () => {
beforeEach(() => {
mockUseExtendTimeWindow.mockReturnValue(inert);
@@ -119,4 +132,47 @@ describe('NoData', () => {
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('hides the global extend action for a panel with a fixed time preference', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(
<NoData
onRetry={onRetry}
panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)}
/>,
);
// Only Retry — extending the dashboard window can't widen a locked panel span.
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('still offers the global extend action for a panel that follows the global window', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.global_time)} />,
);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
);
});
it('keeps the View modal extender even when the panel has a fixed time preference', () => {
const storeExtend = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(
<NoData panel={panelWith(DashboardtypesTimePreferenceDTO.last_6_hr)} />,
);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -193,7 +193,7 @@ function BarPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -13,7 +13,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },

View File

@@ -106,7 +106,7 @@ function HistogramPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -9,7 +9,7 @@ export const sections: SectionConfig[] = [
},
{
kind: SectionKind.Legend,
controls: { position: true },
controls: { position: true, colors: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(

View File

@@ -143,7 +143,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
) : (
<>
<div

View File

@@ -130,6 +130,7 @@ function NumberPanelRenderer({
data-testid="number-panel-no-data"
isFetching={isFetching}
onRetry={refetch}
panel={panel}
/>
) : (
<ValueDisplay

View File

@@ -95,7 +95,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
) : (
<Pie
data={slices}

View File

@@ -1,13 +1,13 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
// (position + per-slice color overrides).
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -160,7 +160,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
) : (
<div className={styles.container}>
<Table

View File

@@ -194,7 +194,7 @@ function TimeSeriesPanelRenderer({
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
@@ -27,7 +27,7 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** True once this panel's section enters the viewport — gates the fetch. */
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
@@ -43,15 +43,7 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
const timePreference = (
panel.spec.plugin.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);

View File

@@ -89,6 +89,14 @@ function section(
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
// Untitled root plus two titled sections — exercises the multi-target submenu.
const ROOT_AND_TWO_TITLED = [
section(0, undefined),
section(1, 'A'),
section(2, 'B'),
];
// Just the free-flow root: an ungrouped board with no sections to move between.
const ONLY_ROOT = [section(0, undefined)];
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
// translation it drives is covered by buildCreateAlertUrl's own tests.
@@ -111,7 +119,9 @@ const baseArgs = {
panelId: 'panel-1',
panel: mockPanel,
data: mockData,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
// Panel sits in a titled section with an untitled root present, so every
// action — including "Move to section" (→ Dashboard root) — is available.
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
};
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
@@ -210,7 +220,7 @@ describe('usePanelActionItems', () => {
]);
});
it('move is disabled when there is no other titled section to move to', () => {
it('hides "Move to section" when the only untitled section is not the root (index 0)', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
@@ -220,8 +230,8 @@ describe('usePanelActionItems', () => {
},
}),
);
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
expect(move).toMatchObject({ disabled: true });
// An untitled section only counts as the root at layoutIndex 0.
expect(itemKeys(result.current)).not.toContain('move');
});
it('edit opens the panel editor for this panel', () => {
@@ -233,14 +243,77 @@ describe('usePanelActionItems', () => {
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('move targets call the mutation with from/to layout indexes', () => {
it('"Move to section" offers a single "Dashboard (root)" target', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { key: string; onClick: () => void }[];
};
expect(move.children).toHaveLength(1);
expect(move.children.map((c) => c.key)).toStrictEqual(['move-to-root']);
});
it('the "Dashboard (root)" target moves the panel to the untitled root section', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { onClick: () => void }[];
};
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('an ungrouped panel (in the root) can move into each titled section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: ROOT_AND_TWO_TITLED },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; label: string }[] };
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1', 'move-2']);
expect(move.children.map((c) => c.label)).toStrictEqual(['A', 'B']);
});
it('a panel in a titled section can move to the root and the other titled sections', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: ROOT_AND_TWO_TITLED },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; label: string }[] };
// Root leads, then the other titled section — never the current one (A).
expect(move.children.map((c) => c.key)).toStrictEqual([
'move-to-root',
'move-2',
]);
expect(move.children.map((c) => c.label)).toStrictEqual([
'Dashboard (root)',
'B',
]);
});
it('moves between titled sections even when the board has no untitled root', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
}),
);
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as { children: { key: string; onClick: () => void }[] };
expect(move.children.map((c) => c.key)).toStrictEqual(['move-1']);
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
@@ -249,47 +322,14 @@ describe('usePanelActionItems', () => {
});
});
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
it('hides "Move to section" when the board has no sections (only the root)', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
panelActions: { currentLayoutIndex: 0, sections: ONLY_ROOT },
}),
);
expect(itemKeys(result.current)).toContain('move-to-root');
});
it('"Move out of section" moves the panel to the untitled root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
const moveOut = result.current.items.find(
(i) => 'key' in i && i.key === 'move-to-root',
);
(moveOut as { onClick: () => void }).onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('hides "Move out of section" when the panel already sits in the root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('hides "Move out of section" when every section is titled (no root)', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('move-to-root');
expect(itemKeys(result.current)).not.toContain('move');
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
@@ -312,7 +352,7 @@ describe('usePanelActionItems', () => {
});
expect(mockDeletePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
layoutIndex: 1,
});
expect(result.current.deleteConfirm.open).toBe(false);
});
@@ -325,7 +365,7 @@ describe('usePanelActionItems', () => {
(clone as { onClick: () => void }).onClick();
expect(mockClonePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
layoutIndex: 1,
});
});

View File

@@ -5,6 +5,7 @@
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
cursor: grab;
min-height: 32px;
}
.headerLeft {

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { Fragment, useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -74,6 +74,14 @@ function PanelHeader({
[warning],
);
/**
* Hide the entire header when there's no title, description, or status to show,
* and the actions menu is suppressed (editor preview).
*/
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
return <Fragment />;
}
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>

View File

@@ -1,9 +1,8 @@
@use '../../../../../../styles/scrollbar' as *;
.modal {
:global(.ant-modal-body) {
padding: 0px;
}
.dialog[data-width] {
max-width: 85%;
width: 85%;
}
// Truncate a long panel name so the header stays on one line; the wrapping tooltip

View File

@@ -1,7 +1,15 @@
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogHeader,
DialogTitle,
} from '@signozhq/ui/dialog';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Modal } from 'antd';
import { ConfigProvider } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useRef } from 'react';
import ViewPanelModalContent from './ViewPanelModalContent';
import styles from './ViewPanelModal.module.scss';
@@ -25,27 +33,48 @@ function ViewPanelModal({
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
// Render antd popups into the dialog (not document.body) so they stay inside the
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
const contentRef = useRef<HTMLDivElement>(null);
return (
<Modal
<Dialog
open={open}
onCancel={onClose}
footer={null}
centered
width="85%"
destroyOnClose
className={styles.modal}
title={
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name} - (View mode)
</Typography.Text>
</TooltipSimple>
}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
>
{open && panel && panelId && (
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
)}
</Modal>
<DialogContent
ref={contentRef}
position="center"
width="extra-wide"
className={styles.dialog}
>
<DialogHeader>
<DialogTitle>
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
</DialogTitle>
</DialogHeader>
<DialogCloseButton />
{open && panel && panelId && (
<ConfigProvider
getPopupContainer={(): HTMLElement => contentRef.current ?? document.body}
>
<ViewPanelModalContent
panel={panel}
panelId={panelId}
onClose={onClose}
/>
</ConfigProvider>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import ListColumnsEditor from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
@@ -44,6 +45,7 @@ function ViewPanelModalContent({
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
@@ -54,7 +56,17 @@ function ViewPanelModalContent({
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
@@ -119,6 +131,15 @@ function ViewPanelModalContent({
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={draft.spec}
onChangeSpec={setSpec}
signal={signal}
/>
) : undefined
}
/>
</div>
<div className={styles.body}>
@@ -128,6 +149,7 @@ function ViewPanelModalContent({
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}

View File

@@ -35,6 +35,8 @@ interface UseViewPanelModeArgs {
export interface UseViewPanelModeReturn {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
/** Update the draft's spec in place (e.g. the List columns editor). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderablePanelDefinition;
/**
@@ -159,6 +161,7 @@ export function useViewPanelMode({
return {
draft,
setSpec,
panelDefinition,
signal,
queryType: currentQuery.queryType,

View File

@@ -1,9 +1,12 @@
import { FolderInput, FolderOutput } from '@signozhq/icons';
import { FolderInput } from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { findRootSection, type DashboardSection } from '../../../utils';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
// Matches the root option label in the New Panel picker (SectionPicker).
const ROOT_LABEL = 'Dashboard (root)';
interface MoveItemsArgs {
sections: DashboardSection[];
currentLayoutIndex: number;
@@ -12,9 +15,11 @@ interface MoveItemsArgs {
}
/**
* The "Move to section" submenu plus a direct "Move out of section" to the
* untitled root, shown only when the panel sits in a titled section and a root
* section exists to receive it.
* "Move to section" submenu listing every section the panel can move to the
* dashboard root (untitled top level, labelled "Dashboard (root)") plus every
* titled section, minus the one the panel already sits in. Hidden entirely when
* there is nowhere to move: an ungrouped board with no titled sections, or a
* panel that is alone in the only section.
*/
export function buildMoveItems({
sections,
@@ -22,44 +27,38 @@ export function buildMoveItems({
panelId,
movePanel,
}: MoveItemsArgs): MenuItem[] {
const rootSection = findRootSection(sections);
// Sections are already in layout order, so the root (index 0, when present)
// naturally leads. Untitled non-root layouts are never a move target.
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
(section) =>
section.layoutIndex !== currentLayoutIndex &&
(section === rootSection || Boolean(section.title)),
);
const items: MenuItem[] = [
if (targets.length === 0) {
return [];
}
return [
{
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
children: targets.map((section) => {
const isRoot = section === rootSection;
return {
key: isRoot ? 'move-to-root' : `move-${section.layoutIndex}`,
label: isRoot ? ROOT_LABEL : (section.title as string),
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: section.layoutIndex,
}),
};
}),
},
];
const rootSection = findRootSection(sections);
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
items.push({
key: 'move-to-root',
label: 'Move out of section',
icon: <FolderOutput size={14} />,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: rootSection.layoutIndex,
}),
});
}
return items;
}

View File

@@ -1,9 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
@@ -41,12 +39,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
targetLayoutIndex,
} = useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
@@ -77,17 +69,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
/>
);
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
// Untitled section — just the grid, no header chrome.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -98,7 +87,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}

View File

@@ -20,3 +20,9 @@
}
}
}
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
.panelWrapper {
height: 100%;
width: 100%;
}

View File

@@ -2,9 +2,9 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import SectionGridItem from './SectionGridItem';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
@@ -12,8 +12,6 @@ const ResponsiveGridLayout = WidthProvider(GridLayout);
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
}
@@ -21,7 +19,6 @@ interface SectionGridProps {
function SectionGrid({
items,
layoutIndex,
isVisible,
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -61,10 +58,9 @@ function SectionGrid({
// panel with no content.
<div key={item.id}>
{item.panel && (
<Panel
<SectionGridItem
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
panelActions={
isEditable
? {

View File

@@ -0,0 +1,47 @@
import { useRef } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
import styles from './SectionGrid.module.scss';
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
panelActions?: PanelActionsConfig;
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
*/
function SectionGridItem({
panel,
panelId,
panelActions,
}: SectionGridItemProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
true,
);
return (
<div ref={containerRef} className={styles.panelWrapper}>
<Panel
panel={panel}
panelId={panelId}
isVisible={isVisible}
panelActions={panelActions}
/>
</div>
);
}
export default SectionGridItem;

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
@@ -6,6 +5,7 @@ import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { selectVariablesExpanded } from '../store/slices/collapseSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './AddVariableFull';
import AddVariableIcon from './AddVariableIcon';
@@ -45,10 +45,13 @@ interface VariablesBarProps {
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const dashboardId = dashboard.id ?? '';
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
const [expanded, setExpanded] = useState(false);
// Persisted per dashboard so the full/collapsed view survives reloads.
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
@@ -75,7 +78,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
onClick={(): void => setVariablesExpanded(dashboardId, !expanded)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
@@ -120,7 +123,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
</div>
))}
{hasOverflow && (
{(expanded || hasOverflow) && (
<span className={styles.moreButton}>
{expanded ? (
moreButton

View File

@@ -57,6 +57,8 @@ function ValueSelector({
onRetry={onRetry}
showSearch
placeholder="Select value"
maxTagCount={2}
maxTagTextLength={20}
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)

View File

@@ -1,74 +1,196 @@
import { act, renderHook } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStaleCheck } from '../useDashboardStaleCheck';
const mockUseQuery = jest.fn();
const mockUseIsMutating = jest.fn();
jest.mock('react-query', () => ({
useQuery: (...args: unknown[]): unknown => mockUseQuery(...args),
useIsMutating: (): number => mockUseIsMutating(),
useQueryClient: (): unknown => ({ getQueryData: jest.fn() }),
}));
jest.mock('api/generated/services/dashboard', () => ({
getDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/d1']),
}));
function setServerUpdatedAt(updatedAt: string | undefined): void {
mockUseQuery.mockReturnValue({ data: { data: { updatedAt } } });
const mockGetDashboard = getDashboardV2 as jest.Mock;
const SPEC_A = { panels: { p1: {} } };
const SPEC_B = { panels: { p1: {}, p2: {} } };
interface Content {
spec?: unknown;
tags?: unknown;
locked?: boolean;
}
// Defaults make loaded and server content identical unless a field is overridden.
function withDefaults(content: Content): Record<string, unknown> {
return { spec: SPEC_A, tags: [], locked: false, ...content };
}
function loaded(
updatedAt: string,
content: Content = {},
): DashboardtypesGettableDashboardV2DTO {
return {
id: 'd1',
updatedAt,
...withDefaults(content),
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
function setVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => state,
});
}
/** Simulate the tab/window coming back with the given server copy. */
async function comeBack(
serverUpdatedAt: string,
content: Content = {},
via: 'visibilitychange' | 'focus' = 'visibilitychange',
): Promise<void> {
mockGetDashboard.mockResolvedValueOnce({
data: { updatedAt: serverUpdatedAt, ...withDefaults(content) },
});
setVisibility('visible');
await act(async () => {
if (via === 'focus') {
window.dispatchEvent(new Event('focus'));
} else {
document.dispatchEvent(new Event('visibilitychange'));
}
});
}
describe('useDashboardStaleCheck', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsMutating.mockReturnValue(0);
setVisibility('visible');
});
it('prompts when the server copy is newer than the loaded one', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
it('does not fetch on first load — only when the tab/window returns', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
expect(result.current.showPrompt).toBe(true);
expect(mockGetDashboard).not.toHaveBeenCalled();
await comeBack('2026-07-08T09:00:00Z');
expect(mockGetDashboard).toHaveBeenCalledWith({ id: 'd1' });
});
it('does not prompt when the versions match', () => {
setServerUpdatedAt('2026-07-08T09:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
it('also probes on window focus (returning from another app)', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T09:00:00Z', {}, 'focus');
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
});
it('prompts when newer and the spec differs', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
it('prompts when newer and the lock state differs (external lock)', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(
loaded('2026-07-08T09:00:00Z', { locked: false }),
jest.fn(),
),
);
await comeBack('2026-07-08T10:00:00Z', { locked: true });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
it('prompts when newer and the tags differ', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(
loaded('2026-07-08T09:00:00Z', { tags: [] }),
jest.fn(),
),
);
await comeBack('2026-07-08T10:00:00Z', {
tags: [{ key: 'env', value: 'prod' }],
});
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
it('does NOT prompt when newer but content (spec/tags/locked) is unchanged', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
// updatedAt bumped but every content field matches — e.g. our own lock, already
// reflected in the render cache.
await comeBack('2026-07-08T10:00:00Z');
expect(result.current.showPrompt).toBe(false);
});
it('does not prompt while a mutation is in flight (optimistic save)', () => {
it('does not prompt when the loaded copy is newer (own edit)', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T10:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T09:00:00Z', { spec: SPEC_B });
expect(result.current.showPrompt).toBe(false);
});
it('does not prompt while a mutation is in flight (optimistic save)', async () => {
mockUseIsMutating.mockReturnValue(1);
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
expect(result.current.showPrompt).toBe(false);
});
it('stops prompting for a version once dismissed', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', jest.fn()),
it('does not probe while the tab is hidden', async () => {
renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
expect(result.current.showPrompt).toBe(true);
setVisibility('hidden');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(mockGetDashboard).not.toHaveBeenCalled();
});
it('stops prompting for a version once dismissed', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
act(() => result.current.dismiss());
expect(result.current.showPrompt).toBe(false);
});
it('reload refetches and clears the dismissed state', () => {
setServerUpdatedAt('2026-07-08T10:00:00Z');
it('reload refetches and closes the prompt immediately', async () => {
const refetch = jest.fn();
const { result } = renderHook(() =>
useDashboardStaleCheck('d1', '2026-07-08T09:00:00Z', refetch),
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), refetch),
);
act(() => result.current.dismiss());
expect(result.current.showPrompt).toBe(false);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
act(() => result.current.reload());
expect(refetch).toHaveBeenCalledTimes(1);
expect(result.current.showPrompt).toBe(true);
expect(result.current.showPrompt).toBe(false);
});
it('re-prompts when a newer, different version appears after a reload', async () => {
const { result } = renderHook(() =>
useDashboardStaleCheck(loaded('2026-07-08T09:00:00Z'), jest.fn()),
);
await comeBack('2026-07-08T10:00:00Z', { spec: SPEC_B });
act(() => result.current.reload());
expect(result.current.showPrompt).toBe(false);
await comeBack('2026-07-08T10:05:00Z', { spec: { panels: { p3: {} } } });
await waitFor(() => expect(result.current.showPrompt).toBe(true));
});
});

View File

@@ -1,4 +1,7 @@
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
export interface PanelTimeWindow {
@@ -65,6 +68,28 @@ const TIME_PREFERENCE_LABEL: Partial<
},
};
/** A panel's saved `visualization.timePreference`, or `undefined` when the kind has none. */
export function getPanelTimePreference(
panel: DashboardtypesPanelDTO,
): DashboardtypesTimePreferenceDTO | undefined {
const pluginSpec = panel.spec.plugin.spec;
if (pluginSpec && 'visualization' in pluginSpec) {
return pluginSpec.visualization?.timePreference;
}
return undefined;
}
/** True when the panel is locked to a fixed relative window, so it ignores the dashboard's ambient time. */
export function panelHasFixedTimePreference(
panel: DashboardtypesPanelDTO,
): boolean {
const timePreference = getPanelTimePreference(panel);
return (
timePreference !== undefined &&
timePreference !== DashboardtypesTimePreferenceDTO.global_time
);
}
export interface PanelTimePreferenceLabel {
/** Compact pill label, e.g. `6h`. */
short: string;

View File

@@ -1,65 +1,92 @@
import { useCallback, useState } from 'react';
import { useIsMutating, useQuery, useQueryClient } from 'react-query';
import {
getDashboardV2,
getGetDashboardV2QueryKey,
} from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import { getDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIsMutating } from 'react-query';
interface UseDashboardStaleCheck {
// Server copy has a newer updatedAt than the loaded one, and not yet dismissed.
// Server copy is a newer version with different content, and not yet dismissed.
showPrompt: boolean;
reload: () => void;
dismiss: () => void;
}
/**
* Detects when the open dashboard changed on the server (another tab/user) without
* touching the render cache: a separate query (own key) refetches on window focus
* and its updatedAt is compared to the loaded copy's. Guarded by in-flight
* mutations so an optimistic save doesn't false-positive.
* Prompts when another tab/user changed the open dashboard. The page's own query is frozen
* (`staleTime: Infinity`), so we re-probe the server on tab/app return (not on first load) and
* prompt only when it's strictly newer AND its content (spec, tags, or lock state) differs from
* what we're viewing — so our own edits, which advance the render cache, don't trip it.
* In-flight mutations are skipped.
*/
export function useDashboardStaleCheck(
dashboardId: string,
loadedUpdatedAt: string | undefined,
dashboard: DashboardtypesGettableDashboardV2DTO,
refetch: () => void,
): UseDashboardStaleCheck {
const {
id: dashboardId,
updatedAt: loadedUpdatedAt,
spec: loadedSpec,
tags: loadedTags,
locked: loadedLocked,
} = dashboard;
const isMutating = useIsMutating() > 0;
const queryClient = useQueryClient();
const [server, setServer] = useState<DashboardtypesGettableDashboardV2DTO>();
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
const { data } = useQuery(
['dashboard-freshness', dashboardId],
({ signal }) => getDashboardV2({ id: dashboardId }, signal),
{
enabled: !!dashboardId,
refetchOnWindowFocus: true,
refetchOnMount: false,
retry: false,
// Seed from the already-loaded dashboard so mount makes no extra GET; the
// query only hits the network on a later window focus.
initialData: () =>
queryClient.getQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: dashboardId }),
),
},
useEffect(() => {
if (!dashboardId) {
return undefined;
}
const probe = async (): Promise<void> => {
// `visibilitychange` also fires on the hidden transition — only probe on return.
if (document.visibilityState !== 'visible') {
return;
}
try {
const response = await getDashboardV2({ id: dashboardId });
setServer(response.data);
} catch {
// A failed freshness probe must never surface to the viewer.
}
};
// `visibilitychange` catches tab switches; `focus` catches app/window switches.
document.addEventListener('visibilitychange', probe);
window.addEventListener('focus', probe);
return (): void => {
document.removeEventListener('visibilitychange', probe);
window.removeEventListener('focus', probe);
};
}, [dashboardId]);
const serverUpdatedAt = server?.updatedAt;
// Content the viewer cares about: panels/layout/variables (spec), tags, and lock state.
// Deep, order-independent so an optimistic patch's key reordering isn't read as a change.
const contentDiffers = useMemo(
() =>
!!server &&
(!isEqual(server.spec, loadedSpec) ||
!isEqual(server.tags, loadedTags) ||
server.locked !== loadedLocked),
[server, loadedSpec, loadedTags, loadedLocked],
);
const serverUpdatedAt = data?.data?.updatedAt;
const changed =
!isMutating &&
!!serverUpdatedAt &&
!!loadedUpdatedAt &&
serverUpdatedAt !== loadedUpdatedAt;
new Date(serverUpdatedAt).getTime() > new Date(loadedUpdatedAt).getTime() &&
contentDiffers;
const dismiss = useCallback(
(): void => setDismissedAt(serverUpdatedAt ?? null),
[serverUpdatedAt],
);
// Dismiss + refetch: close on click and pull the latest; a newer, different version re-prompts.
const reload = useCallback((): void => {
setDismissedAt(serverUpdatedAt ?? null);
refetch();
setDismissedAt(null);
}, [refetch]);
}, [refetch, serverUpdatedAt]);
return {
showPrompt: changed && serverUpdatedAt !== dismissedAt,

View File

@@ -55,11 +55,7 @@ function DashboardContainer({
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const staleCheck = useDashboardStaleCheck(
dashboard.id,
dashboard.updatedAt,
refetch,
);
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
// In full screen show only the sections and panels — the header/toolbar chrome
// is hidden for a clean presentation view (exit with Esc).

View File

@@ -11,6 +11,10 @@ import type { DashboardStore } from '../useDashboardStore';
export interface CollapseSlice {
collapsed: Record<string, Record<string, boolean>>;
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
// Whether the variables bar shows its full (expanded) list, per dashboard.
// Absent → collapsed (the default).
variablesExpanded: Record<string, boolean>;
setVariablesExpanded: (dashboardId: string, expanded: boolean) => void;
}
export const createCollapseSlice: StateCreator<
@@ -32,4 +36,17 @@ export const createCollapseSlice: StateCreator<
},
});
},
variablesExpanded: {},
setVariablesExpanded: (dashboardId, expanded): void => {
const { variablesExpanded } = get();
set({
variablesExpanded: { ...variablesExpanded, [dashboardId]: expanded },
});
},
});
/** Selector: is the variables bar expanded for this dashboard? Default collapsed. */
export const selectVariablesExpanded =
(dashboardId: string) =>
(state: DashboardStore): boolean =>
Boolean(state.variablesExpanded[dashboardId]);

View File

@@ -49,6 +49,7 @@ export const useDashboardStore = create<DashboardStore>()(
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variablesExpanded: state.variablesExpanded,
variableValues: state.variableValues,
}),
},

View File

@@ -12,6 +12,7 @@ import {
LockKeyhole,
PenLine,
SquareArrowOutUpRight,
Tag,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import {
@@ -30,6 +31,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import EditTagsModal from './EditTagsModal';
import RenameDashboardModal from './RenameDashboardModal';
import styles from './ActionsPopover.module.scss';
@@ -39,6 +41,8 @@ interface Props {
dashboardName: string;
createdBy: string;
isLocked: boolean;
// Current tags as `key:value` strings, for the inline tag editor.
tags: string[];
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
canEdit: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
@@ -50,6 +54,7 @@ function ActionsPopover({
dashboardName,
createdBy,
isLocked,
tags,
canEdit,
onView,
}: Props): JSX.Element {
@@ -57,6 +62,7 @@ function ActionsPopover({
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isEditTagsOpen, setIsEditTagsOpen] = useState(false);
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
// by the caller; open the copy so it can be tweaked right away.
@@ -165,6 +171,35 @@ function ActionsPopover({
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
placement="left"
title={
isLocked
? 'This dashboard is locked, so its tags cannot be edited.'
: ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Tag size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsEditTagsOpen(true);
}
}}
testId="dashboard-action-edit-tags"
>
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<Button
color="secondary"
@@ -231,6 +266,12 @@ function ActionsPopover({
currentName={dashboardName}
onClose={(): void => setIsRenameOpen(false)}
/>
<EditTagsModal
open={isEditTagsOpen}
dashboardId={dashboardId}
currentTags={tags}
onClose={(): void => setIsEditTagsOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { keyValueStringsToTags } from '../../utils/helpers';
import styles from './ActionsPopover.module.scss';
interface Props {
open: boolean;
dashboardId: string;
// Current tags as `key:value` strings.
currentTags: string[];
onClose: () => void;
}
/**
* Edits a dashboard's tags from the list via an `add /tags` patch, then refreshes
* the list. `add` is used (not `replace`) so it works whether the tags field is
* already present or absent on the stored dashboard.
*/
function EditTagsModal({
open,
dashboardId,
currentTags,
onClose,
}: Props): JSX.Element {
const [tags, setTags] = useState<string[]>(currentTags);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
// Reset to the dashboard's tags when the modal opens. Keyed by content
// (`tagsKey`) rather than array identity, since `currentTags` is a fresh array
// on each parent render; reconstructing from the key keeps the effect deps
// stable and the reset off the per-render path.
const tagsKey = currentTags.join('\n');
useEffect(() => {
if (open) {
setTags(tagsKey ? tagsKey.split('\n') : []);
}
}, [open, tagsKey]);
const { mutate: runSave, isLoading } = useMutation({
mutationFn: () => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/tags',
value: keyValueStringsToTags(tags),
},
];
return patchDashboardV2({ id: dashboardId }, ops);
},
onSuccess: async () => {
toast.success('Tags updated');
await invalidateListDashboardsForUserV2(queryClient);
onClose();
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
// Save on Cmd/Ctrl+Enter from anywhere in the modal (not just the tag input),
// while it is open.
useEffect(() => {
if (!open) {
return undefined;
}
const onKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runSave();
}
};
window.addEventListener('keydown', onKeyDown);
return (): void => window.removeEventListener('keydown', onKeyDown);
}, [open, runSave]);
const title = currentTags.length > 0 ? 'Edit tags' : 'Add tags';
return (
<DialogWrapper
title={title}
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.renameFooter}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="edit-tags-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
loading={isLoading}
disabled={isLoading}
onClick={(): void => runSave()}
testId="edit-tags-submit"
>
Save
</Button>
</div>
}
>
<TagKeyValueInput
tags={tags}
onTagsChange={setTags}
placeholder="key:value (press Enter)"
testId="edit-dashboard-tags"
/>
</DialogWrapper>
);
}
export default EditTagsModal;

View File

@@ -60,6 +60,12 @@ function DashboardRow({
);
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
// Clicks inside portaled overlays (the actions menu, edit modals) bubble here
// through React's tree even though they render outside the row in the DOM.
// Only navigate when the click actually landed inside the row.
if (!event.currentTarget.contains(event.target as Node)) {
return;
}
event.stopPropagation();
markViewed(id);
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
@@ -159,6 +165,7 @@ function DashboardRow({
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
tags={tags}
canEdit={canEdit}
onView={onClickHandler}
/>

View File

@@ -17,6 +17,8 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { keyValueStringsToTags } from '../../utils/helpers';
import DashboardImagePicker from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker';
import { Base64Icons } from '../../../DashboardPageV2/DashboardContainer/DashboardSettings/Overview/utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
import styles from './NewDashboardModal.module.scss';
@@ -32,6 +34,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const [name, setName] = useState(DEFAULT_NAME);
const [description, setDescription] = useState('');
const [image, setImage] = useState<string>(Base64Icons[0]);
const [tags, setTags] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
@@ -48,6 +51,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
image,
tags: postableTags.length ? postableTags : null,
spec: {
display: {
@@ -77,21 +81,29 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<Typography.Text className={styles.label}>
Title <Typography.Text className={styles.required}>*</Typography.Text>
</Typography.Text>
<Input
value={name}
autoFocus
maxLength={DASHBOARD_NAME_MAX_LENGTH}
placeholder="e.g. Sample Dashboard"
testId="create-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void handleCreate();
<div className={styles.titleRow}>
<DashboardImagePicker
image={image}
onChange={setImage}
triggerClassName={styles.imageTrigger}
/>
<Input
className={styles.titleInput}
value={name}
autoFocus
maxLength={DASHBOARD_NAME_MAX_LENGTH}
placeholder="e.g. Sample Dashboard"
testId="create-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
}}
/>
onKeyDown={(e): void => {
if (e.key === 'Enter') {
void handleCreate();
}
}}
/>
</div>
</div>
<div className={styles.field}>

View File

@@ -35,6 +35,28 @@
color: var(--danger-background);
}
// Title row: icon picker joined flush to the left of the name input.
.titleRow {
display: flex;
gap: 4px;
align-items: center;
// The icon picker renders its dropdown inline (no portal); lift it above the
// description/tags fields below it.
:global([data-radix-popper-content-wrapper]) {
z-index: 1100 !important;
}
}
.imageTrigger {
border-radius: 2px 0 0 2px;
}
.titleInput {
flex: 1;
border-radius: 0 2px 2px 0;
}
.hint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
@@ -60,28 +82,64 @@
}
}
.cardName {
color: var(--l1-foreground);
// "From a template" tab: a centered browse-and-request placeholder until the
// templates BE lands. Fixed height matches .panel so switching tabs won't resize.
.templatesPanel {
height: 460px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 24px;
text-align: center;
}
.templatesIcon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-bottom: 4px;
border-radius: 10px;
color: var(--bg-robin-400);
background: var(--callout-primary-background);
border: 1px solid var(--callout-primary-border);
}
.templatesDesc {
max-width: 360px;
}
.browseLink {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: var(--bg-robin-400);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.cardDesc {
color: var(--l2-foreground);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.requestRow {
border-top: 1px solid var(--l2-border);
padding-top: 12px;
:global(.request-entity-container) {
gap: 10px 16px;
padding: 14px 16px;
&:hover {
color: var(--bg-robin-300);
}
}
.requestForm {
display: flex;
gap: 8px;
width: 100%;
max-width: 380px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--l2-border);
}
.requestInput {
flex: 1;
}
.importHeader {
font-size: var(--font-size-sm);
color: var(--l2-foreground);
@@ -108,73 +166,6 @@
margin-top: 8px;
}
.templatesLayout {
display: flex;
gap: 12px;
flex: 1;
min-height: 0;
}
.templatesList {
flex: none;
width: 200px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
padding-right: 4px;
}
.templateItem {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
transition:
background 0.12s,
border-color 0.12s;
}
.templateItem:hover {
background: var(--l2-background);
}
.templateItemActive {
background: var(--l2-background);
border-color: var(--primary-background);
}
.templateName {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.templateCat {
color: var(--l3-foreground);
font-size: var(--font-size-xs);
}
.templatesPreview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.previewHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.jsonError {
display: flex;
align-items: center;

View File

@@ -43,7 +43,7 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
{
key: 'template',
label: 'From a template',
children: <TemplatesPanel onClose={onClose} />,
children: <TemplatesPanel />,
},
{
key: 'import',

View File

@@ -1,67 +1,120 @@
import { useState } from 'react';
import { generatePath } from 'react-router-dom';
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import {
Check,
LayoutDashboard,
LoaderCircle,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { AxiosError } from 'axios';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import styles from './NewDashboardModal.module.scss';
interface Props {
onClose: () => void;
}
const TEMPLATES_DOCS_URL =
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
// so the action creates a blank dashboard.
function TemplatesPanel({ onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [creating, setCreating] = useState(false);
// Templates aren't served by the BE yet, so this tab is a browse-and-request
// placeholder: link out to the published template library, and let cloud users
// request one we haven't built.
function TemplatesPanel(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleCreate = async (): Promise<void> => {
if (creating) {
const requestName = name.trim();
const handleRequest = async (): Promise<void> => {
if (!requestName || submitting) {
return;
}
try {
setCreating(true);
logEvent('Dashboard List: Use template clicked', {});
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: 'Sample Dashboard' },
layouts: [],
panels: {},
variables: [],
},
setSubmitting(true);
const response = await logEvent('Dashboard Requested', {
screen: 'Dashboard list page',
dashboard: requestName,
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
setCreating(false);
if (response.statusCode === 200) {
toast.success('Dashboard request submitted');
setName('');
} else {
toast.error(response.error || 'Something went wrong');
}
} catch {
toast.error('Something went wrong');
} finally {
setSubmitting(false);
}
};
return (
<div className={styles.panel}>
<div className="new-dashboard-templates-modal">
<DashboardTemplatesContent
onCreateNewDashboard={(): void => {
void handleCreate();
}}
/>
</div>
<div className={styles.templatesPanel}>
<span className={styles.templatesIcon}>
<LayoutDashboard size={20} />
</span>
<Typography variant="title" size="lg" weight="semibold">
Dashboard templates
</Typography>
<Typography
variant="text"
size="sm"
color="muted"
className={styles.templatesDesc}
>
Browse our library of ready-made dashboards, or request a new one and
we&apos;ll build it for you.
</Typography>
<a
className={styles.browseLink}
href={TEMPLATES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Browse dashboard templates
<SquareArrowOutUpRight size={14} />
</a>
{isCloudUser && (
<div className={styles.requestForm}>
<Input
className={styles.requestInput}
placeholder="Enter dashboard name..."
value={name}
testId="request-dashboard-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
void handleRequest();
}
}}
/>
<Button
variant="solid"
color="primary"
size="md"
disabled={submitting || requestName.length === 0}
testId="request-dashboard-submit"
prefix={
submitting ? (
<LoaderCircle size={14} className={styles.spinner} />
) : (
<Check size={14} />
)
}
onClick={(): void => {
void handleRequest();
}}
>
Submit
</Button>
</div>
)}
</div>
);
}

View File

@@ -11,9 +11,9 @@ export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,
): string[] =>
(tags ?? []).map((tag) =>
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
);
// Always render both sides (so an env:env tag reads "env:env", not a lone
// "env"); fall back to the bare key only when there's no value.
(tags ?? []).map((tag) => (tag.value ? `${tag.key}:${tag.value}` : tag.key));
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
// postable tag DTO shape. The first colon separates key from value.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const containerNameAttrKey = inframonitoringtypes.ContainerNameAttrKey
const (
containerStatusStateAttrKey = "k8s.container.status.state"
containerStatusReasonAttrKey = "k8s.container.status.reason"
)
const (
containerStatusStateMetricName = "k8s.container.status.state"
containerStatusReasonMetricName = "k8s.container.status.reason"
containerReadyMetricName = "k8s.container.ready"
containerRestartsMetricName = "k8s.container.restarts"
)
// containerStatusMetricNamesList are the metrics required to derive the
// kubectl-style container display status. Gated by
// getPerGroupContainerStatusCountsWithReqMetricChecks — if either is missing,
// status is skipped and a warning is returned.
var containerStatusMetricNamesList = []string{
containerStatusStateMetricName,
containerStatusReasonMetricName,
}
var containerNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: containerNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// containerRowGroupBy is the default row-identity groupBy for the containers
// list: (k8s.pod.uid, k8s.container.name). Stable across container restarts.
// podUIDGroupByKey is shared with the pods module (pods_constants.go).
var containerRowGroupBy = []qbtypes.GroupByKey{podUIDGroupByKey, containerNameGroupByKey}
// containersTableMetricNamesList are the metrics that carry the container
// attributes; used for metadata resolution and the retention check.
var containersTableMetricNamesList = []string{
"container.cpu.usage",
"k8s.container.cpu_request_utilization",
"k8s.container.cpu_limit_utilization",
"container.memory.working_set",
"k8s.container.memory_request_utilization",
"k8s.container.memory_limit_utilization",
"k8s.container.restarts",
"k8s.container.ready",
"k8s.container.status.reason",
"k8s.container.status.state",
}
var containerAttrKeysForMetadata = []string{
"k8s.pod.uid",
"k8s.container.name",
"k8s.pod.name",
"container.image.name",
"container.image.tag",
"k8s.namespace.name",
"k8s.node.name",
"k8s.deployment.name",
"k8s.statefulset.name",
"k8s.daemonset.name",
"k8s.job.name",
"k8s.cronjob.name",
"k8s.cluster.name",
}
var orderByToContainersQueryNames = map[string][]string{
inframonitoringtypes.ContainersOrderByCPU: {"A"},
inframonitoringtypes.ContainersOrderByCPURequest: {"B"},
inframonitoringtypes.ContainersOrderByCPULimit: {"C"},
inframonitoringtypes.ContainersOrderByMemory: {"D"},
inframonitoringtypes.ContainersOrderByMemoryRequest: {"E"},
inframonitoringtypes.ContainersOrderByMemoryLimit: {"F"},
}
// newContainersTableListQuery builds the composite QB v5 request for the
// containers list (kubeletstats usage/utilization). Status, restarts and ready
// come from k8sclusterreceiver via dedicated queries (works for both list and
// grouped_list modes), so not included here.
func (m *module) newContainersTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: CPU usage (cores)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "container.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
// Query B: CPU request utilization
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.container.cpu_request_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
// Query C: CPU limit utilization
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.container.cpu_limit_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
// Query D: Memory working set
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "container.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
// Query E: Memory request utilization
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "E",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.container.memory_request_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
// Query F: Memory limit utilization
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "F",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.container.memory_limit_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: containerRowGroupBy,
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -94,6 +94,30 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListContainers(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableContainers
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListContainers(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListNodes(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {

View File

@@ -132,3 +132,61 @@ func (s checkSpec) getAllAttrs() []string {
}
return out
}
// containerStatusCounts holds per-group container counts bucketed by latest
// kubectl-style display status in window. Mirrors inframonitoringtypes.ContainerCountsByStatus.
type containerStatusCounts struct {
// State fallback.
Running int
Waiting int
Terminated int
// Container-level reasons.
CrashLoopBackOff int
ImagePullBackOff int
ErrImagePull int
CreateContainerConfigError int
ContainerCreating int
OOMKilled int
Completed int
Error int
ContainerCannotRun int
Unknown int
}
// containerStatusCountsToResponse copies the internal per-group status counts
// into the public response struct.
func containerStatusCountsToResponse(c containerStatusCounts) inframonitoringtypes.ContainerCountsByStatus {
return inframonitoringtypes.ContainerCountsByStatus{
Running: c.Running,
Waiting: c.Waiting,
Terminated: c.Terminated,
CrashLoopBackOff: c.CrashLoopBackOff,
ImagePullBackOff: c.ImagePullBackOff,
ErrImagePull: c.ErrImagePull,
CreateContainerConfigError: c.CreateContainerConfigError,
ContainerCreating: c.ContainerCreating,
OOMKilled: c.OOMKilled,
Completed: c.Completed,
Error: c.Error,
ContainerCannotRun: c.ContainerCannotRun,
Unknown: c.Unknown,
}
}
// containerReadyCounts holds per-group container counts bucketed by latest
// readiness in window. Mirrors inframonitoringtypes.ContainerCountsByReady.
type containerReadyCounts struct {
Ready int
NotReady int
}
// containerReadyCountsToResponse copies the internal per-group ready counts
// into the public response struct.
func containerReadyCountsToResponse(c containerReadyCounts) inframonitoringtypes.ContainerCountsByReady {
return inframonitoringtypes.ContainerCountsByReady{
Ready: c.Ready,
NotReady: c.NotReady,
}
}

View File

@@ -357,6 +357,110 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (*inframonitoringtypes.Containers, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListContainers")
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Containers{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.ContainersOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = containerRowGroupBy
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
minFirstReportedUnixMilli, err := m.getEarliestMetricTime(ctx, containersTableMetricNamesList)
if err != nil {
return nil, err
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.ContainerRecord{}
resp.Total = 0
return resp, nil
}
metadataMap, err := m.getContainersTableMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopContainerGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.ContainerRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newContainersTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
statusCounts map[string]containerStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
readyCounts map[string]containerReadyCounts
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
isContainerNameAndPodUIDInGroupBy := isKeyInGroupByAttrs(req.GroupBy, containerNameAttrKey) && isKeyInGroupByAttrs(req.GroupBy, podUIDAttrKey)
resp.Records = buildContainerRecords(isContainerNameAndPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, statusCounts, restartCounts, readyCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, statusWarning)
return resp, nil
}
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListNodes")

View File

@@ -8,15 +8,11 @@ import (
)
const (
podUIDAttrKey = "k8s.pod.uid"
podStartTimeAttrKey = "k8s.pod.start_time"
containerNameAttrKey = "k8s.container.name"
containerStatusReasonAttrKey = "k8s.container.status.reason"
podUIDAttrKey = "k8s.pod.uid"
podStartTimeAttrKey = "k8s.pod.start_time"
podPhaseMetricName = "k8s.pod.phase"
podStatusReasonMetricName = "k8s.pod.status_reason"
containerStatusReasonMetricName = "k8s.container.status.reason"
containerRestartsMetricName = "k8s.container.restarts"
podPhaseMetricName = "k8s.pod.phase"
podStatusReasonMetricName = "k8s.pod.status_reason"
)
// podStatusMetricNamesList are the metrics required to derive the kubectl-style
@@ -45,8 +41,8 @@ var podsTableMetricNamesList = []string{
"k8s.pod.memory_limit_utilization",
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.container.restarts",
// "k8s.container.status.reason",
// "k8s.container.restarts",
}
var podAttrKeysForMetadata = []string{

View File

@@ -12,6 +12,7 @@ import (
type Handler interface {
ListHosts(http.ResponseWriter, *http.Request)
ListPods(http.ResponseWriter, *http.Request)
ListContainers(http.ResponseWriter, *http.Request)
ListNodes(http.ResponseWriter, *http.Request)
ListNamespaces(http.ResponseWriter, *http.Request)
ListClusters(http.ResponseWriter, *http.Request)
@@ -27,6 +28,7 @@ type Module interface {
statsreporter.StatsCollector
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
ListContainers(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (*inframonitoringtypes.Containers, error)
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error)
ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error)

View File

@@ -60,7 +60,7 @@ func newConfig() factory.Config {
Path: "/var/lib/signoz/signoz.db",
Mode: "wal",
BusyTimeout: 10000 * time.Millisecond, // increasing the defaults from https://github.com/mattn/go-sqlite3/blob/master/sqlite3.go#L1098 because of transpilation from C to GO
TransactionMode: "deferred",
TransactionMode: "immediate",
},
}

View File

@@ -9,17 +9,18 @@ type CheckType struct {
}
var (
CheckTypeHosts = CheckType{valuer.NewString("hosts")}
CheckTypeProcesses = CheckType{valuer.NewString("processes")}
CheckTypePods = CheckType{valuer.NewString("pods")}
CheckTypeNodes = CheckType{valuer.NewString("nodes")}
CheckTypeDeployments = CheckType{valuer.NewString("deployments")}
CheckTypeDaemonsets = CheckType{valuer.NewString("daemonsets")}
CheckTypeStatefulsets = CheckType{valuer.NewString("statefulsets")}
CheckTypeJobs = CheckType{valuer.NewString("jobs")}
CheckTypeNamespaces = CheckType{valuer.NewString("namespaces")}
CheckTypeClusters = CheckType{valuer.NewString("clusters")}
CheckTypeVolumes = CheckType{valuer.NewString("volumes")}
CheckTypeHosts = CheckType{valuer.NewString("hosts")}
CheckTypeProcesses = CheckType{valuer.NewString("processes")}
CheckTypePods = CheckType{valuer.NewString("pods")}
CheckTypeNodes = CheckType{valuer.NewString("nodes")}
CheckTypeDeployments = CheckType{valuer.NewString("deployments")}
CheckTypeDaemonsets = CheckType{valuer.NewString("daemonsets")}
CheckTypeStatefulsets = CheckType{valuer.NewString("statefulsets")}
CheckTypeJobs = CheckType{valuer.NewString("jobs")}
CheckTypeNamespaces = CheckType{valuer.NewString("namespaces")}
CheckTypeClusters = CheckType{valuer.NewString("clusters")}
CheckTypeVolumes = CheckType{valuer.NewString("volumes")}
CheckTypeKubeContainers = CheckType{valuer.NewString("kube_containers")}
)
func (CheckType) Enum() []any {
@@ -35,6 +36,7 @@ func (CheckType) Enum() []any {
CheckTypeNamespaces,
CheckTypeClusters,
CheckTypeVolumes,
CheckTypeKubeContainers,
}
}
@@ -50,6 +52,7 @@ var ValidCheckTypes = []CheckType{
CheckTypeNamespaces,
CheckTypeClusters,
CheckTypeVolumes,
CheckTypeKubeContainers,
}
// CheckComponentType tags each AssociatedComponent as either a receiver or a processor.

View File

@@ -84,6 +84,11 @@ func TestPostableChecks_Validate(t *testing.T) {
req: &PostableChecks{Type: CheckTypeVolumes},
wantErr: false,
},
{
name: "kube_containers",
req: &PostableChecks{Type: CheckTypeKubeContainers},
wantErr: false,
},
}
for _, tt := range tests {

View File

@@ -0,0 +1,148 @@
package inframonitoringtypes
import (
"encoding/json"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type Containers struct {
Type ResponseType `json:"type" required:"true"`
Records []ContainerRecord `json:"records" required:"true" nullable:"false"`
Total int `json:"total" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
}
// ContainerCountsByStatus buckets container counts by their latest kubectl-style
// display status in the time window (see ContainerStatus). One field per
// derivable status. Populated in both list and grouped_list modes.
type ContainerCountsByStatus struct {
// State fallback.
Running int `json:"running" required:"true"`
Waiting int `json:"waiting" required:"true"`
Terminated int `json:"terminated" required:"true"`
// Container-level reasons (k8s.container.status.reason allowlist).
CrashLoopBackOff int `json:"crashLoopBackOff" required:"true"`
ImagePullBackOff int `json:"imagePullBackOff" required:"true"`
ErrImagePull int `json:"errImagePull" required:"true"`
CreateContainerConfigError int `json:"createContainerConfigError" required:"true"`
ContainerCreating int `json:"containerCreating" required:"true"`
OOMKilled int `json:"oomKilled" required:"true"`
Completed int `json:"completed" required:"true"`
Error int `json:"error" required:"true"`
ContainerCannotRun int `json:"containerCannotRun" required:"true"`
Unknown int `json:"unknown" required:"true"`
}
// ContainerCountsByReady buckets container counts by their latest readiness
// (k8s.container.ready) in the time window. Populated in both modes.
type ContainerCountsByReady struct {
Ready int `json:"ready" required:"true"`
NotReady int `json:"notReady" required:"true"`
}
type ContainerRecord struct {
// Row identity: (k8s.pod.uid, k8s.container.name). Stable across container
// restarts (unlike container.id), globally unique, present on both receivers.
PodUID string `json:"podUID" required:"true"`
ContainerName string `json:"containerName" required:"true"`
// Health (k8sclusterreceiver). Single value in list mode; counts in grouped_list mode.
Status ContainerStatus `json:"status" required:"true"`
ContainerCountsByStatus ContainerCountsByStatus `json:"containerCountsByStatus" required:"true"`
Ready ContainerReady `json:"ready" required:"true"`
ContainerCountsByReady ContainerCountsByReady `json:"containerCountsByReady" required:"true"`
// Absolute restart count: own count (list mode) or group sum (grouped mode). -1 when no data.
Restarts int64 `json:"restarts" required:"true"`
// Usage / utilization (kubeletstats). -1 when no data.
CPU float64 `json:"cpu" required:"true"` // container.cpu.usage (cores)
CPURequestUtilization float64 `json:"cpuRequestUtilization" required:"true"` // k8s.container.cpu_request_utilization
CPULimitUtilization float64 `json:"cpuLimitUtilization" required:"true"` // k8s.container.cpu_limit_utilization
Memory float64 `json:"memory" required:"true"` // container.memory.working_set
MemoryRequestUtilization float64 `json:"memoryRequestUtilization" required:"true"` // k8s.container.memory_request_utilization
MemoryLimitUtilization float64 `json:"memoryLimitUtilization" required:"true"` // k8s.container.memory_limit_utilization
Meta map[string]string `json:"meta" required:"true"`
}
// PostableContainers is the request body for the v2 containers list API.
type PostableContainers struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// Validate ensures PostableContainers contains acceptable values.
func (req *PostableContainers) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.Start <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid start time %d: start must be greater than 0",
req.Start,
)
}
if req.End <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid end time %d: end must be greater than 0",
req.End,
)
}
if req.Start >= req.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid time range: start (%d) must be less than end (%d)",
req.Start,
req.End,
)
}
if req.Limit < 1 || req.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.OrderBy != nil {
if !slices.Contains(ContainersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
}
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == ContainerNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", ContainerNameAttrKey)
}
}
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *PostableContainers) UnmarshalJSON(data []byte) error {
type raw PostableContainers
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = PostableContainers(decoded)
return req.Validate()
}

View File

@@ -0,0 +1,92 @@
package inframonitoringtypes
import "github.com/SigNoz/signoz/pkg/valuer"
// ContainerStatus is the kubectl-style display status of a container, derived
// from k8s.container.status.state (base) + k8s.container.status.reason (overlay).
type ContainerStatus struct {
valuer.String
}
var (
// State fallback (from k8s.container.status.state).
ContainerStatusRunning = ContainerStatus{valuer.NewString("Running")}
ContainerStatusWaiting = ContainerStatus{valuer.NewString("Waiting")}
ContainerStatusTerminated = ContainerStatus{valuer.NewString("Terminated")}
// Reasons (from k8s.container.status.reason allowlist).
ContainerStatusCrashLoopBackOff = ContainerStatus{valuer.NewString("CrashLoopBackOff")}
ContainerStatusImagePullBackOff = ContainerStatus{valuer.NewString("ImagePullBackOff")}
ContainerStatusErrImagePull = ContainerStatus{valuer.NewString("ErrImagePull")}
ContainerStatusCreateContainerConfigError = ContainerStatus{valuer.NewString("CreateContainerConfigError")}
ContainerStatusContainerCreating = ContainerStatus{valuer.NewString("ContainerCreating")}
ContainerStatusOOMKilled = ContainerStatus{valuer.NewString("OOMKilled")}
ContainerStatusCompleted = ContainerStatus{valuer.NewString("Completed")}
ContainerStatusError = ContainerStatus{valuer.NewString("Error")}
ContainerStatusContainerCannotRun = ContainerStatus{valuer.NewString("ContainerCannotRun")}
ContainerStatusUnknown = ContainerStatus{valuer.NewString("Unknown")}
// ContainerStatusNoData is the record default: no status data / metrics disabled.
ContainerStatusNoData = ContainerStatus{valuer.NewString("no_data")}
)
func (ContainerStatus) Enum() []any {
return []any{
ContainerStatusRunning,
ContainerStatusWaiting,
ContainerStatusTerminated,
ContainerStatusCrashLoopBackOff,
ContainerStatusImagePullBackOff,
ContainerStatusErrImagePull,
ContainerStatusCreateContainerConfigError,
ContainerStatusContainerCreating,
ContainerStatusOOMKilled,
ContainerStatusCompleted,
ContainerStatusError,
ContainerStatusContainerCannotRun,
ContainerStatusUnknown,
ContainerStatusNoData,
}
}
// ContainerReady is the latest readiness of a container (k8s.container.ready).
type ContainerReady struct {
valuer.String
}
var (
ContainerReadyReady = ContainerReady{valuer.NewString("ready")}
ContainerReadyNotReady = ContainerReady{valuer.NewString("not_ready")}
// ContainerReadyNoData is the record default: no readiness data.
ContainerReadyNoData = ContainerReady{valuer.NewString("no_data")}
)
func (ContainerReady) Enum() []any {
return []any{
ContainerReadyReady,
ContainerReadyNotReady,
ContainerReadyNoData,
}
}
const ContainerNameAttrKey = "k8s.container.name"
const (
ContainersOrderByCPU = "cpu"
ContainersOrderByCPURequest = "cpu_request"
ContainersOrderByCPULimit = "cpu_limit"
ContainersOrderByMemory = "memory"
ContainersOrderByMemoryRequest = "memory_request"
ContainersOrderByMemoryLimit = "memory_limit"
)
var ContainersValidOrderByKeys = []string{
ContainersOrderByCPU,
ContainersOrderByCPURequest,
ContainersOrderByCPULimit,
ContainersOrderByMemory,
ContainersOrderByMemoryRequest,
ContainersOrderByMemoryLimit,
ContainerNameAttrKey,
}

View File

@@ -62,12 +62,6 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
parser.addoption(
"--sqlite-transaction-mode",
action="store",
default="deferred",
help="sqlite transaction mode",
)
parser.addoption(
"--postgres-version",
action="store",

View File

@@ -30,7 +30,6 @@ def sqlite(
assert result.fetchone()[0] == 1
mode = pytestconfig.getoption("--sqlite-mode")
transaction_mode = pytestconfig.getoption("--sqlite-transaction-mode")
return types.TestContainerSQL(
container=types.TestContainerDocker(
id="",
@@ -42,7 +41,6 @@ def sqlite(
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
"SIGNOZ_SQLSTORE_SQLITE_MODE": mode,
"SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE": transaction_mode,
},
)

View File

@@ -0,0 +1,28 @@
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crun-uid", "k8s.container.name": "app", "k8s.pod.name": "crun", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cnr-uid", "k8s.container.name": "app", "k8s.pod.name": "cnr", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cclo-uid", "k8s.container.name": "app", "k8s.pod.name": "cclo", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a", "k8s.container.status.reason": "ImagePullBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cimg-uid", "k8s.container.name": "app", "k8s.pod.name": "cimg", "k8s.namespace.name": "ns-a", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.reason": "Completed"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "ccomp-uid", "k8s.container.name": "app", "k8s.pod.name": "ccomp", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b", "k8s.container.status.reason": "OOMKilled"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "coom-uid", "k8s.container.name": "app", "k8s.pod.name": "coom", "k8s.namespace.name": "ns-b", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -0,0 +1,4 @@
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.15, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cwarn-uid", "k8s.container.name": "app", "k8s.pod.name": "cwarn", "k8s.namespace.name": "ns-warn", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-warn"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -0,0 +1,16 @@
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.state": "running"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "crec-uid", "k8s.container.name": "app", "k8s.pod.name": "crec", "k8s.namespace.name": "ns-rec", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-rec"}, "timestamp": "2025-01-10T10:03:00+00:00", "value": 5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -0,0 +1,12 @@
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.05, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.state": "terminated"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.reason": "Completed"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cterm-uid", "k8s.container.name": "app", "k8s.pod.name": "cterm", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.05, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.state", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.state": "waiting"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb", "k8s.container.status.reason": "ContainerCreating"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.ready", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.restarts", "labels": {"k8s.pod.uid": "cwait-uid", "k8s.container.name": "app", "k8s.pod.name": "cwait", "k8s.namespace.name": "ns-fb", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-fb"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -0,0 +1,12 @@
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.25, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.cpu_request_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.cpu_limit_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.memory_request_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.memory_limit_utilization", "labels": {"k8s.pod.uid": "acc1-uid", "k8s.container.name": "app", "k8s.pod.name": "acc1", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.cpu.usage", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.75, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.cpu_request_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.9, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.cpu_limit_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.7, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "container.memory.working_set", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5000000, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.memory_request_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.8, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.memory_limit_utilization", "labels": {"k8s.pod.uid": "acc2-uid", "k8s.container.name": "app", "k8s.pod.name": "acc2", "k8s.namespace.name": "ns-acc", "k8s.node.name": "node-a", "k8s.cluster.name": "cluster-x", "container.image.name": "nginx", "container.image.tag": "1.27", "k8s.deployment.name": "dep-acc"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -0,0 +1,22 @@
{
"records": [
{
"podName": "acc1",
"cpu": 0.25,
"cpuRequestUtilization": 0.5,
"cpuLimitUtilization": 0.3,
"memory": 2000000,
"memoryRequestUtilization": 0.6,
"memoryLimitUtilization": 0.4
},
{
"podName": "acc2",
"cpu": 0.75,
"cpuRequestUtilization": 0.9,
"cpuLimitUtilization": 0.7,
"memory": 5000000,
"memoryRequestUtilization": 0.8,
"memoryLimitUtilization": 0.5
}
]
}

View File

@@ -112,6 +112,14 @@ SPECS = {
"optional": {},
"attrs": {KAP: ["k8s.persistentvolumeclaim.name", "k8s.namespace.name"], RDP: ["k8s.cluster.name"]},
},
"kube_containers": {
"default": {KSR: ["container.cpu.usage", "container.memory.working_set"], KCR: ["k8s.container.restarts", "k8s.container.ready"]},
"optional": {
KSR: ["k8s.container.cpu_request_utilization", "k8s.container.cpu_limit_utilization", "k8s.container.memory_request_utilization", "k8s.container.memory_limit_utilization"],
KCR: ["k8s.container.status.state", "k8s.container.status.reason"],
},
"attrs": {KAP: ["k8s.pod.uid", "k8s.container.name"]},
},
}
ALL_TYPES = list(SPECS.keys())

View File

@@ -0,0 +1,425 @@
"""Integration tests for the v2 infra-monitoring kube_containers endpoint."""
import json
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
ENDPOINT = "/api/v2/infra_monitoring/kube_containers"
def test_kube_containers_accuracy(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""Exact per-container usage/utilization values against precomputed expected
output. (Status metrics are intentionally absent here, so status is no_data
and a status warning is surfaced — this test asserts only the numeric fields.)"""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_value_accuracy.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
with open(
get_testdata_file_path("inframonitoring/kube_containers_value_accuracy_expected.json"),
encoding="utf-8",
) as f:
expected = json.load(f)
exp_by_name = {r["podName"]: r for r in expected["records"]}
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"filter": {"expression": "k8s.namespace.name = 'ns-acc'"},
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["total"] == len(expected["records"])
assert {r["meta"]["k8s.pod.name"] for r in data["records"]} == set(exp_by_name.keys())
for record in data["records"]:
exp = exp_by_name[record["meta"]["k8s.pod.name"]]
for field in (
"cpu",
"cpuRequestUtilization",
"cpuLimitUtilization",
"memory",
"memoryRequestUtilization",
"memoryLimitUtilization",
):
assert compare_values(record[field], exp[field], 1e-9), f"{record['meta']['k8s.pod.name']}.{field}: got {record[field]}, expected {exp[field]}"
def test_kube_containers_status_health_and_base_set(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""List mode: per-container status / ready / restarts derived from the
k8s_cluster health metrics, AND the base-set union — containers with only
health metrics and NO kubeletstats usage (ImagePullBackOff / Completed /
OOMKilled) still appear, with cpu = -1 sentinel."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
# pod name -> (status, ready, restarts, has_usage)
expected = {
"crun": ("running", "ready", 0, True),
"cnr": ("running", "not_ready", 0, True),
"cclo": ("crashloopbackoff", "not_ready", 5, True),
"cimg": ("imagepullbackoff", "not_ready", 0, False),
"ccomp": ("completed", "not_ready", 0, False),
"coom": ("oomkilled", "not_ready", 3, False),
}
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
assert set(expected).issubset(set(by_pod)), f"missing containers: {set(expected) - set(by_pod)}"
for pod, (status, ready, restarts, has_usage) in expected.items():
rec = by_pod[pod]
assert rec["status"] == status, f"{pod}.status: got {rec['status']}, expected {status}"
assert rec["ready"] == ready, f"{pod}.ready: got {rec['ready']}, expected {ready}"
assert rec["restarts"] == restarts, f"{pod}.restarts: got {rec['restarts']}, expected {restarts}"
if has_usage:
assert rec["cpu"] != -1, f"{pod}: expected cpu populated, got {rec['cpu']}"
else:
assert rec["cpu"] == -1, f"{pod}: expected cpu -1 sentinel (base-set-only), got {rec['cpu']}"
def test_kube_containers_status_counts_grouped_mode(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""Grouped mode (groupBy namespace): per-group counts of distinct containers
by status and readiness."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["type"] == "grouped_list"
by_ns = {r["meta"]["k8s.namespace.name"]: r for r in data["records"]}
assert {"ns-a", "ns-b"}.issubset(set(by_ns))
ns_a = by_ns["ns-a"]["containerCountsByStatus"]
assert ns_a["running"] == 2
assert ns_a["crashLoopBackOff"] == 1
assert ns_a["imagePullBackOff"] == 1
assert by_ns["ns-a"]["containerCountsByReady"] == {"ready": 1, "notReady": 3}
ns_b = by_ns["ns-b"]["containerCountsByStatus"]
assert ns_b["completed"] == 1
assert ns_b["oomKilled"] == 1
assert by_ns["ns-b"]["containerCountsByReady"] == {"ready": 0, "notReady": 2}
def test_kube_containers_status_recency(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""Status derivation is by recency, not priority. A container that
CrashLoopBackOff'd earlier in the window (restarts=5) but is Running now
(state flipped to running, reason series flipped to 0) must report the
CURRENT status 'running' — while restarts=5 preserves the crash history.
A priority/argMax-on-reason bug would wrongly surface 'crashloopbackoff'."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_recency.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
assert "crec" in by_pod, f"recency container missing: {list(by_pod)}"
rec = by_pod["crec"]
assert rec["status"] == "running", f"recency: expected current status running, got {rec['status']}"
assert rec["ready"] == "ready", f"recency: expected ready, got {rec['ready']}"
assert rec["restarts"] == 5, f"recency: expected restarts=5 (history preserved), got {rec['restarts']}"
def test_kube_containers_status_state_only_fallback(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""When a container has a state but NO active reason, display status falls
through to the state itself: state='terminated' -> 'terminated',
state='waiting' -> 'waiting'. Exercises the state-fallback branches of the
multiIf and the container_reason LEFT-JOIN miss (containers.go:410)."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_state_fallback.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
assert {"cterm", "cwait"}.issubset(set(by_pod)), f"fallback containers missing: {list(by_pod)}"
assert by_pod["cterm"]["status"] == "terminated", f"terminated fallback: got {by_pod['cterm']['status']}"
assert by_pod["cwait"]["status"] == "waiting", f"waiting fallback: got {by_pod['cwait']['status']}"
def test_kube_containers_status_warning_missing_metrics(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""Status metrics (k8s.container.status.state/reason) never ingested: the
container still appears (usage present), status gates to 'no_data' with a
non-blocking warning naming the missing metrics, while the independent
ready / restarts health signals are still computed correctly."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_missing_status.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
data = body["data"]
by_pod = {r["meta"]["k8s.pod.name"]: r for r in data["records"]}
assert "cwarn" in by_pod, f"container missing: {list(by_pod)}"
rec = by_pod["cwarn"]
assert rec["status"] == "no_data", f"expected status no_data (metrics disabled), got {rec['status']}"
assert rec["ready"] == "ready", f"ready should still compute, got {rec['ready']}"
assert rec["restarts"] == 2, f"restarts should still compute, got {rec['restarts']}"
assert rec["cpu"] != -1, f"usage present, cpu should be populated, got {rec['cpu']}"
warnings = get_all_warnings(body)
assert any("status.state" in w["message"] and "status.reason" in w["message"] for w in warnings), f"status warning naming the missing metrics not surfaced: {warnings!r}"
def test_kube_containers_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""Filter expression narrows the result set by resource attribute."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def pods_for(expr: str) -> set[str]:
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"filter": {"expression": expr},
"limit": 50,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
pods = {r["meta"]["k8s.pod.name"] for r in data["records"]}
assert data["total"] == len(pods)
return pods
assert pods_for("k8s.namespace.name = 'ns-a'") == {"crun", "cnr", "cclo", "cimg"}
assert pods_for("k8s.namespace.name = 'ns-b'") == {"ccomp", "coom"}
assert pods_for("k8s.pod.name = 'crun'") == {"crun"}
def test_kube_containers_orderby_and_pagination(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""orderBy cpu desc ranks usage containers by cpu (crun>cnr>cclo) with the
usage-less base-set (cpu=-1) backfilled last; pagination walks the full set
(total=6) in non-overlapping pages preserving that order."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/kube_containers_dataset.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def page(limit: int, offset: int) -> dict:
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"orderBy": {"key": {"name": "cpu"}, "direction": "desc"},
"limit": limit,
"offset": offset,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
return response.json()["data"]
full = page(50, 0)
assert full["total"] == 6
order = [r["meta"]["k8s.pod.name"] for r in full["records"]]
assert order[:3] == ["crun", "cnr", "cclo"], f"cpu-desc ranking wrong: {order}"
assert set(order) == {"crun", "cnr", "cclo", "cimg", "ccomp", "coom"}
# Paginate 2 at a time; pages must be disjoint, cover everything, keep order.
seen: list[str] = []
for off in (0, 2, 4):
p = page(2, off)
assert p["total"] == 6, f"total must stay 6 at offset {off}, got {p['total']}"
pods = [r["meta"]["k8s.pod.name"] for r in p["records"]]
assert len(pods) == 2, f"offset {off}: expected 2 records, got {pods}"
seen.extend(pods)
assert seen == order, f"paginated sequence {seen} != full-page order {order}"
@pytest.mark.parametrize(
("payload_override", "err_substr"),
[
pytest.param({"limit": 0}, "limit must be between", id="limit_zero"),
pytest.param({"limit": 5001}, "limit must be between", id="limit_too_large"),
pytest.param({"offset": -1}, "offset cannot be negative", id="offset_negative"),
pytest.param(
{"orderBy": {"key": {"name": "bogus"}, "direction": "desc"}},
"invalid order by key",
id="orderby_invalid_key",
),
pytest.param(
{"orderBy": {"key": {"name": "cpu"}, "direction": "up"}},
"invalid order by direction",
id="orderby_invalid_direction",
),
pytest.param(
{
"orderBy": {"key": {"name": "k8s.container.name"}, "direction": "asc"},
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
},
"is only allowed when groupBy is empty",
id="orderby_container_name_with_groupby",
),
],
)
def test_kube_containers_validation_errors(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
payload_override: dict,
err_substr: str,
) -> None:
"""PostableContainers.Validate() rules reject with 400 + descriptive error."""
now = datetime.now(tz=UTC).replace(microsecond=0)
body: dict = {
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
}
body.update(payload_override)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(signoz.self.host_configs["8080"].get(ENDPOINT), headers={"authorization": f"Bearer {token}"}, json=body, timeout=5)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
error = response.json()["error"]
assert error["code"] == "invalid_input"
assert err_substr.lower() in error["message"].lower(), f"expected {err_substr!r} in: {error['message']!r}"