-
- {metadataConfig.map((config) => (
-
- {config.label}
-
- ))}
-
-
-
- {metadataConfig.map((config) => {
- const value = config.getValue(entity);
+ {metadataConfig.map((config) => {
+ const value = config.getValue(entity);
- if (config.render) {
- return config.render(value, entity);
- }
-
- const displayValue = String(value);
- return (
-
- {displayValue}
-
- );
- })}
-
+ return (
+
+ );
+ })}
{countsConfig &&
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
index 82e65cdc19d..5fd324bc37f 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
@@ -296,6 +296,7 @@ export function K8sBaseList<
params.selectedItem,
params.clusterName,
params.namespaceName,
+ params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: record });
}
@@ -348,6 +349,12 @@ export function K8sBaseList<
params.namespaceName,
);
}
+ if (params.containerName) {
+ url.searchParams.set(
+ INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME,
+ params.containerName,
+ );
+ }
} else {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss
index b118df1e251..f65f0eabee3 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss
@@ -19,6 +19,12 @@
& [data-hide-expanded='true'] {
display: none;
}
+
+ // The icon slot is rendered even when the icon inside it is hidden, and the
+ // header's flex gap then indents the title past the values below it.
+ & [data-slot='icon']:has([data-hide-expanded='true']) {
+ display: none;
+ }
}
.expandedTableFooter {
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx
index 728a1f5d8e0..3c327ccbd3b 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx
@@ -214,6 +214,7 @@ export function K8sExpandedRow<
params.selectedItem,
params.clusterName,
params.namespaceName,
+ params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: row });
}
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss
new file mode 100644
index 00000000000..012a5718f6a
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss
@@ -0,0 +1,44 @@
+.metadataItem {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-1);
+ min-width: 0;
+}
+
+.valueRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-1);
+ min-width: 0;
+}
+
+.label {
+ letter-spacing: 0.44px;
+ text-transform: uppercase;
+}
+
+// Single-line ellipsis rather than Typography's `truncate`, which line-clamps:
+// clamping still wraps the text, so a value breaking at a hyphen ends its line
+// early and leaves a gap between the ellipsis and the copy button.
+//
+// This has to be a block: overflow and text-overflow do not apply to inline
+// boxes, and Typography.Text renders an inline span.
+.value {
+ display: block;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.valueText {
+ font-family: var(--periscope-font-family-mono);
+}
+
+// Sized to the icon rather than the default 2rem tap target, so it sits flush
+// against the value instead of floating in its own block of padding.
+.copyButton {
+ --button-padding: 2px;
+ --button-height: auto;
+ --button-width: auto;
+}
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx
new file mode 100644
index 00000000000..0993b419f36
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx
@@ -0,0 +1,63 @@
+import { ReactNode, useCallback } from 'react';
+import { toast } from '@signozhq/ui/sonner';
+import { TooltipSimple } from '@signozhq/ui/tooltip';
+import { Typography } from '@signozhq/ui/typography';
+import CopyButton from 'periscope/components/CopyButton/CopyButton';
+
+import styles from './EntityMetadataItem.module.scss';
+
+export interface EntityMetadataItemProps {
+ label: string;
+ value: string;
+ /** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */
+ renderedValue?: ReactNode;
+}
+
+export function EntityMetadataItem({
+ label,
+ value,
+ renderedValue,
+}: EntityMetadataItemProps): JSX.Element {
+ const handleCopy = useCallback((): void => {
+ toast.success(`${label} copied to clipboard`, { position: 'bottom-left' });
+ }, [label]);
+
+ return (
+
+
+ {label}
+
+
+ {renderedValue ?? (
+
+
+
+
+ {value}
+
+
+
+ {!!value && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx
new file mode 100644
index 00000000000..cdfb0a19480
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx
@@ -0,0 +1,110 @@
+import { render, screen, userEvent, waitFor } from 'tests/test-utils';
+
+import { EntityMetadataItem } from '../EntityMetadataItem';
+
+const mockCopyToClipboard = jest.fn();
+
+jest.mock('react-use', () => ({
+ __esModule: true,
+ useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
+}));
+
+const mockToastSuccess = jest.fn();
+
+jest.mock('@signozhq/ui/sonner', () => ({
+ ...jest.requireActual('@signozhq/ui/sonner'),
+ toast: {
+ success: (...args: unknown[]): unknown => mockToastSuccess(...args),
+ },
+}));
+
+describe('EntityMetadataItem', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the label and its value', () => {
+ render(
);
+
+ expect(screen.getByText('Cluster Name')).toBeInTheDocument();
+ expect(screen.getByText('prod-cluster')).toBeInTheDocument();
+ });
+
+ it('copies the full value and confirms which field was copied', async () => {
+ render(
+
,
+ );
+
+ await userEvent.click(screen.getByTestId('copy-metadata-image:tag'));
+
+ await waitFor(() => {
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
+ 'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator',
+ );
+ });
+ expect(mockToastSuccess).toHaveBeenCalledWith(
+ 'Image:Tag copied to clipboard',
+ expect.anything(),
+ );
+ });
+
+ it('offers no copy control when the value is empty', () => {
+ render(
);
+
+ expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
+ });
+
+ it('exposes the full value on hover', async () => {
+ render(
+
,
+ );
+
+ await userEvent.hover(
+ screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length,
+ ).toBeGreaterThan(1);
+ });
+ });
+
+ it('never presents the value as clickable', () => {
+ render(
);
+
+ const valueEl = screen.getByText('a-very-long-node-name');
+ expect(valueEl).not.toHaveAttribute('data-interactive');
+ expect(valueEl).not.toHaveAttribute('data-truncate');
+ });
+
+ it('triggers the tooltip from the wrapper, never from the text itself', () => {
+ render(
);
+
+ // Radix merges its handlers onto the trigger, and Typography styles
+ // itself interactive off any merged onClick — so the trigger has to stay
+ // off the text.
+ const textEl = screen.getByText('a-very-long-node-name');
+ expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger');
+ expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger');
+ });
+
+ it('leaves an entity-supplied renderer alone', () => {
+ render(
+
custom node}
+ />,
+ );
+
+ expect(screen.getByTestId('custom')).toBeInTheDocument();
+ expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts
index 9783aa3c150..a5cbeb1846e 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts
+++ b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts
@@ -11,6 +11,7 @@ import { jobEntityConfig } from '../Jobs/entity.config';
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
import { volumeEntityConfig } from '../Volumes/entity.config';
+import { containerEntityConfig } from '../Containers/entity.config';
type AnyEntityConfig = K8sEntityConfig<
K8sEntityData,
@@ -34,6 +35,7 @@ export const entityRegistry: Record = {
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
+ [K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
};
export function getEntityConfig(category: string): AnyEntityConfig | undefined {
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts
new file mode 100644
index 00000000000..e11e344bfa9
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts
@@ -0,0 +1,142 @@
+import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
+import {
+ k8sContainerGetSelectedItemExpression,
+ k8sContainerInitialEventsExpression,
+ k8sContainerInitialLogTracesExpression,
+} from 'container/InfraMonitoringK8sV2/Containers/constants';
+import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics';
+import {
+ getK8sContainerItemKey,
+ getK8sContainerRowKey,
+} from 'container/InfraMonitoringK8sV2/Containers/table.config';
+import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils';
+
+function makeContainer(
+ overrides: Partial = {},
+): InframonitoringtypesContainerRecordDTO {
+ return {
+ containerName: 'nginx',
+ podUID: 'pod-uid-1',
+ meta: {
+ 'k8s.container.name': 'nginx',
+ 'k8s.pod.uid': 'pod-uid-1',
+ 'k8s.pod.name': 'web-0',
+ 'k8s.namespace.name': 'production',
+ 'k8s.cluster.name': 'prod-cluster',
+ 'container.image.name': 'nginx',
+ 'container.image.tag': '1.27',
+ },
+ ...overrides,
+ } as InframonitoringtypesContainerRecordDTO;
+}
+
+describe('container identity', () => {
+ it('keys a row by the (pod UID, container name) pair', () => {
+ expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx');
+ });
+
+ it('carries the container name alongside the pod UID into the drawer params', () => {
+ expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({
+ selectedItem: 'pod-uid-1',
+ containerName: 'nginx',
+ clusterName: null,
+ namespaceName: null,
+ });
+ });
+
+ it('falls back to meta when the record fields are empty', () => {
+ const container = makeContainer({ containerName: '', podUID: '' });
+
+ expect(getK8sContainerItemKey(container)).toStrictEqual({
+ selectedItem: 'pod-uid-1',
+ containerName: 'nginx',
+ clusterName: null,
+ namespaceName: null,
+ });
+ });
+
+ it('scopes the details fetch to both halves of the identity', () => {
+ expect(
+ k8sContainerGetSelectedItemExpression({
+ selectedItem: 'pod-uid-1',
+ containerName: 'nginx',
+ }),
+ ).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'");
+ });
+});
+
+describe('getContainerImageWithTag', () => {
+ it('renders name and tag together', () => {
+ expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27');
+ });
+
+ it('drops the tag when the image is not pinned', () => {
+ const container = makeContainer({
+ meta: { 'container.image.name': 'nginx' },
+ });
+
+ expect(getContainerImageWithTag(container)).toBe('nginx');
+ });
+
+ it('renders nothing when the image name is missing', () => {
+ expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe('');
+ });
+});
+
+describe('container drawer expressions', () => {
+ it('scopes logs and traces to the container within its pod', () => {
+ expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe(
+ "k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'",
+ );
+ });
+
+ it('scopes events to the pod, since k8s emits events per pod', () => {
+ expect(k8sContainerInitialEventsExpression(makeContainer())).toBe(
+ "k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'",
+ );
+ });
+});
+
+describe('getContainerMetricsQueryPayload', () => {
+ const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000);
+
+ it('returns one payload per documented chart', () => {
+ expect(payloads).toHaveLength(10);
+ });
+
+ it('scopes every query to the selected container', () => {
+ payloads.forEach((payload) => {
+ payload.query.builder.queryData.forEach((query) => {
+ expect(query.filters?.items).toStrictEqual([
+ expect.objectContaining({
+ key: expect.objectContaining({ key: 'k8s.pod.uid' }),
+ op: '=',
+ value: 'pod-uid-1',
+ }),
+ expect.objectContaining({
+ key: expect.objectContaining({ key: 'k8s.container.name' }),
+ op: '=',
+ value: 'nginx',
+ }),
+ ]);
+ });
+ });
+ });
+
+ it('derives cache memory from the working set and RSS queries', () => {
+ const memoryByState = payloads[4];
+
+ expect(
+ memoryByState.query.builder.queryData.map((query) => [
+ query.queryName,
+ query.aggregateAttribute?.key,
+ ]),
+ ).toStrictEqual([
+ ['A', 'container.memory.rss'],
+ ['B', 'container.memory.working_set'],
+ ]);
+ expect(memoryByState.query.builder.queryFormulas).toStrictEqual([
+ expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }),
+ ]);
+ });
+});
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts
new file mode 100644
index 00000000000..f98440c48a4
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts
@@ -0,0 +1,170 @@
+import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
+import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
+
+import {
+ buildEventsExpression,
+ buildLogsTracesExpression,
+} from '../Base/utils';
+import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types';
+import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
+import { SelectedItemParams } from '../hooks';
+import {
+ CONTAINERS_DOC_PATH,
+ getContainerImageWithTag,
+ getContainerName,
+ getContainerPodUID,
+} from './utils';
+
+/** A container row is identified by the (pod UID, container name) pair. */
+export const k8sContainerGetSelectedItemExpression = (
+ params: SelectedItemParams,
+): string =>
+ [
+ `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression(
+ params.selectedItem ?? '',
+ )}`,
+ `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression(
+ params.containerName ?? '',
+ )}`,
+ ].join(' AND ');
+
+export const k8sContainerGetEntityName = getContainerName;
+
+export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig[] =
+ [
+ {
+ label: 'Pod',
+ getValue: (c): string =>
+ c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
+ },
+ {
+ label: 'NAMESPACE',
+ getValue: (c): string =>
+ c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
+ },
+ {
+ label: 'Node',
+ getValue: (c): string =>
+ c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
+ },
+ {
+ label: 'Cluster Name',
+ getValue: (c): string =>
+ c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
+ },
+ {
+ label: 'Image:Tag',
+ getValue: getContainerImageWithTag,
+ },
+ ];
+
+export const k8sContainerInitialLogTracesExpression = (
+ container: InframonitoringtypesContainerRecordDTO,
+): string => {
+ const base = buildLogsTracesExpression({
+ mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
+ mainAttributeValue: getContainerPodUID(container),
+ clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
+ namespaceName:
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
+ });
+
+ const containerName = getContainerName(container);
+ if (!containerName) {
+ return base;
+ }
+
+ const containerClause = `${
+ INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME
+ } = ${formatValueForExpression(containerName)}`;
+
+ return base ? `${base} AND ${containerClause}` : containerClause;
+};
+
+/**
+ * Kubernetes emits events against the pod, not the container, so the events tab
+ * is scoped to the container's pod.
+ */
+export const k8sContainerInitialEventsExpression = (
+ container: InframonitoringtypesContainerRecordDTO,
+): string =>
+ buildEventsExpression({
+ objectKind: 'Pod',
+ objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
+ clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
+ namespaceName:
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
+ });
+
+export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [
+ {
+ title: 'CPU Usage (cores)',
+ yAxisUnit: '',
+ docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`,
+ description:
+ 'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.',
+ },
+ {
+ title: 'CPU Request, Limit Utilization',
+ yAxisUnit: 'percentunit',
+ docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`,
+ description:
+ 'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.',
+ },
+ {
+ title: 'Memory Usage (bytes)',
+ yAxisUnit: 'bytes',
+ docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`,
+ description:
+ 'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.',
+ },
+ {
+ title: 'Memory Request, Limit Utilization',
+ yAxisUnit: 'percentunit',
+ docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`,
+ description:
+ 'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.',
+ },
+ {
+ title: 'Memory by State',
+ yAxisUnit: 'bytes',
+ docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`,
+ description:
+ 'RSS, working set and cache memory of the container, separating heap growth from file cache.',
+ },
+ {
+ title: 'Memory Major Page Faults',
+ yAxisUnit: '',
+ docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`,
+ description:
+ 'Major page fault rate of the container; sustained values mean the working set is paging to disk.',
+ },
+ {
+ title: 'File System (bytes)',
+ yAxisUnit: 'bytes',
+ docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`,
+ description:
+ 'Capacity, available and used bytes of the container filesystem.',
+ },
+ {
+ title: 'Container Uptime',
+ yAxisUnit: 's',
+ docPath: `${CONTAINERS_DOC_PATH}#container-uptime`,
+ description:
+ 'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.',
+ },
+ {
+ title: 'Node CPU Utilization by Container',
+ yAxisUnit: 'percentunit',
+ docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`,
+ description:
+ "The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.",
+ },
+ {
+ title: 'Node Memory Utilization by Container',
+ yAxisUnit: 'percentunit',
+ docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`,
+ description:
+ "The container's memory usage as a fraction of the whole node's capacity.",
+ },
+];
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts
new file mode 100644
index 00000000000..95f33ca214c
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts
@@ -0,0 +1,140 @@
+import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
+import { listContainers } from 'api/generated/services/inframonitoring';
+import {
+ InframonitoringtypesContainerRecordDTO,
+ InframonitoringtypesResponseTypeDTO,
+ Querybuildertypesv5OrderDirectionDTO,
+ RenderErrorResponseDTO,
+} from 'api/generated/services/sigNoz.schemas';
+import { AxiosError } from 'axios';
+import { InfraMonitoringEvents } from 'constants/events';
+
+import { K8sEntityConfig } from '../Base/entity.config.types';
+import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
+import { InfraMonitoringEntity } from '../constants';
+import { SelectedItemParams } from '../hooks';
+import {
+ containerWidgetInfo,
+ k8sContainerDetailsMetadataConfig,
+ k8sContainerGetEntityName,
+ k8sContainerGetSelectedItemExpression,
+ k8sContainerInitialEventsExpression,
+ k8sContainerInitialLogTracesExpression,
+} from './constants';
+import { getContainerMetricsQueryPayload } from './metrics';
+import {
+ getK8sContainerItemKey,
+ getK8sContainerRowKey,
+ k8sContainerColumnsConfig,
+} from './table.config';
+
+async function fetchListData(
+ filters: K8sBaseFilters,
+ signal?: AbortSignal,
+): ReturnType<
+ K8sEntityConfig<
+ InframonitoringtypesContainerRecordDTO,
+ SelectedItemParams
+ >['list']['fetchListData']
+> {
+ try {
+ const response = await listContainers(
+ {
+ filter: { expression: filters.filter.expression },
+ groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
+ offset: filters.offset,
+ limit: filters.limit ?? 10,
+ start: filters.start,
+ end: filters.end,
+ orderBy: filters.orderBy
+ ? {
+ key: { name: filters.orderBy.key.name },
+ direction:
+ filters.orderBy.direction === 'asc'
+ ? Querybuildertypesv5OrderDirectionDTO.asc
+ : Querybuildertypesv5OrderDirectionDTO.desc,
+ }
+ : undefined,
+ },
+ signal,
+ );
+
+ const data = response.data;
+ return {
+ type:
+ data.type === InframonitoringtypesResponseTypeDTO.grouped_list
+ ? ('grouped_list' as const)
+ : ('list' as const),
+ records: data.records,
+ total: data.total,
+ endTimeBeforeRetention: data.endTimeBeforeRetention,
+ warning: data.warning,
+ };
+ } catch (error) {
+ return {
+ type: 'list' as const,
+ records: [] as InframonitoringtypesContainerRecordDTO[],
+ total: 0,
+ error:
+ convertToApiError(error as AxiosError) ?? null,
+ };
+ }
+}
+
+async function fetchEntityData(
+ filters: K8sDetailsFilters,
+ signal?: AbortSignal,
+): ReturnType<
+ K8sEntityConfig['details']['fetchEntityData']
+> {
+ try {
+ const response = await listContainers(
+ {
+ filter: { expression: filters.filter.expression },
+ start: filters.start,
+ end: filters.end,
+ limit: 1,
+ offset: 0,
+ },
+ signal,
+ );
+
+ return {
+ data: response.data.records.length > 0 ? response.data.records[0] : null,
+ };
+ } catch (error) {
+ return {
+ data: null,
+ error:
+ convertToApiError(error as AxiosError) ?? null,
+ };
+ }
+}
+
+export const containerEntityConfig: K8sEntityConfig<
+ InframonitoringtypesContainerRecordDTO,
+ SelectedItemParams
+> = {
+ list: {
+ entity: InfraMonitoringEntity.CONTAINERS,
+ eventCategory: InfraMonitoringEvents.Container,
+ tableColumns: k8sContainerColumnsConfig,
+ fetchListData,
+ getRowKey: getK8sContainerRowKey,
+ getItemKey: getK8sContainerItemKey,
+ detailsQueryKeyPrefix: 'container',
+ },
+ details: {
+ category: InfraMonitoringEntity.CONTAINERS,
+ eventCategory: InfraMonitoringEvents.Container,
+ queryKeyPrefix: 'container',
+ getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
+ fetchEntityData,
+ getEntityName: k8sContainerGetEntityName,
+ getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression,
+ getInitialEventsExpression: k8sContainerInitialEventsExpression,
+ metadataConfig: k8sContainerDetailsMetadataConfig,
+ entityWidgetInfo: containerWidgetInfo,
+ getEntityQueryPayload: getContainerMetricsQueryPayload,
+ },
+};
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts
new file mode 100644
index 00000000000..6fd9cb24dab
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts
@@ -0,0 +1,275 @@
+import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
+import { PANEL_TYPES } from 'constants/queryBuilder';
+import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
+import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
+import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
+import { EQueryType } from 'types/common/dashboard';
+import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
+import { v4 } from 'uuid';
+
+import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
+import { getContainerName, getContainerPodUID } from './utils';
+
+const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F'];
+const STEP_INTERVAL = 60;
+
+type TimeAggregation = 'avg' | 'max' | 'min' | 'latest';
+type SpaceAggregation = 'sum' | 'avg' | 'max';
+
+interface SeriesSpec {
+ metricKey: string;
+ legend: string;
+ timeAggregation: TimeAggregation;
+ spaceAggregation: SpaceAggregation;
+}
+
+interface FormulaSpec {
+ expression: string;
+ legend: string;
+}
+
+/**
+ * Every panel is scoped to a single container by the (k8s.pod.uid,
+ * k8s.container.name) pair that identifies its row in the list.
+ */
+function buildScopeFilters(
+ container: InframonitoringtypesContainerRecordDTO,
+): TagFilter {
+ return {
+ items: [
+ {
+ id: 'pod-uid',
+ key: {
+ dataType: DataTypes.String,
+ id: `k8s_pod_uid--string--tag--false`,
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
+ type: 'tag',
+ },
+ op: '=',
+ value: getContainerPodUID(container),
+ },
+ {
+ id: 'container-name',
+ key: {
+ dataType: DataTypes.String,
+ id: `k8s_container_name--string--tag--false`,
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
+ type: 'tag',
+ },
+ op: '=',
+ value: getContainerName(container),
+ },
+ ],
+ op: 'AND',
+ };
+}
+
+function buildQuery(
+ container: InframonitoringtypesContainerRecordDTO,
+ start: number,
+ end: number,
+ series: SeriesSpec[],
+ formulas: FormulaSpec[] = [],
+): GetQueryResultsProps {
+ const filters = buildScopeFilters(container);
+
+ return {
+ selectedTime: 'GLOBAL_TIME',
+ graphType: PANEL_TYPES.TIME_SERIES,
+ query: {
+ builder: {
+ queryData: series.map((spec, index) => ({
+ aggregateAttribute: {
+ dataType: DataTypes.Float64,
+ id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`,
+ key: spec.metricKey,
+ type: 'Gauge',
+ },
+ aggregateOperator: spec.timeAggregation,
+ dataSource: DataSource.METRICS,
+ disabled: false,
+ expression: QUERY_NAMES[index],
+ filters,
+ functions: [],
+ groupBy: [],
+ having: [],
+ legend: spec.legend,
+ limit: null,
+ orderBy: [],
+ queryName: QUERY_NAMES[index],
+ reduceTo: ReduceOperators.AVG,
+ spaceAggregation: spec.spaceAggregation,
+ stepInterval: STEP_INTERVAL,
+ timeAggregation: spec.timeAggregation,
+ })),
+ queryFormulas: formulas.map((formula, index) => ({
+ disabled: false,
+ expression: formula.expression,
+ legend: formula.legend,
+ queryName: `F${index + 1}`,
+ })),
+ queryTraceOperator: [],
+ },
+ clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
+ id: v4(),
+ promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
+ queryType: EQueryType.QUERY_BUILDER,
+ },
+ variables: {},
+ formatForWeb: false,
+ start,
+ end,
+ };
+}
+
+/** Absolute usage metrics are summed across series, ratios are averaged. */
+function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] {
+ const prefix = legendPrefix ? `${legendPrefix} - ` : '';
+ return [
+ {
+ metricKey,
+ legend: `${prefix}Avg`,
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ {
+ metricKey,
+ legend: `${prefix}Max`,
+ timeAggregation: 'max',
+ spaceAggregation: 'sum',
+ },
+ {
+ metricKey,
+ legend: `${prefix}Min`,
+ timeAggregation: 'min',
+ spaceAggregation: 'sum',
+ },
+ ];
+}
+
+function utilizationSeries(
+ metricKey: string,
+ legendPrefix: string,
+): SeriesSpec[] {
+ return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({
+ metricKey,
+ legend: `${legendPrefix} - ${
+ timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1)
+ }`,
+ timeAggregation,
+ spaceAggregation: 'avg' as const,
+ }));
+}
+
+export const getContainerMetricsQueryPayload = (
+ container: InframonitoringtypesContainerRecordDTO,
+ start: number,
+ end: number,
+): GetQueryResultsProps[] => {
+ const query = (
+ series: SeriesSpec[],
+ formulas?: FormulaSpec[],
+ ): GetQueryResultsProps => buildQuery(container, start, end, series, formulas);
+
+ return [
+ query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)),
+ query([
+ ...utilizationSeries(
+ INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION,
+ 'Request util %',
+ ),
+ ...utilizationSeries(
+ INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION,
+ 'Limit util %',
+ ),
+ ]),
+ query([
+ ...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'),
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE,
+ legend: 'Available',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ ]),
+ query([
+ ...utilizationSeries(
+ INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION,
+ 'Request util %',
+ ),
+ ...utilizationSeries(
+ INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION,
+ 'Limit util %',
+ ),
+ ]),
+ query(
+ [
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS,
+ legend: 'RSS Memory',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET,
+ legend: 'Working Set Memory',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ ],
+ [{ expression: 'B - A', legend: 'Cache Memory' }],
+ ),
+ query([
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS,
+ legend: 'Major Page Faults',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ ]),
+ query([
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY,
+ legend: 'Capacity',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE,
+ legend: 'Available',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE,
+ legend: 'Usage',
+ timeAggregation: 'avg',
+ spaceAggregation: 'sum',
+ },
+ ]),
+ query([
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME,
+ legend: 'Uptime',
+ timeAggregation: 'latest',
+ spaceAggregation: 'sum',
+ },
+ ]),
+ query([
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION,
+ legend: 'Node CPU Utilization',
+ timeAggregation: 'avg',
+ spaceAggregation: 'avg',
+ },
+ ]),
+ query([
+ {
+ metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION,
+ legend: 'Node Memory Utilization',
+ timeAggregation: 'avg',
+ spaceAggregation: 'avg',
+ },
+ ]),
+ ];
+};
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx
new file mode 100644
index 00000000000..9b4c1ecc7bb
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx
@@ -0,0 +1,470 @@
+import { Container } from '@signozhq/icons';
+import { Badge } from '@signozhq/ui/badge';
+import {
+ InframonitoringtypesContainerReadyDTO,
+ InframonitoringtypesContainerRecordDTO,
+ InframonitoringtypesContainerStatusDTO,
+} from 'api/generated/services/sigNoz.schemas';
+import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
+import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
+
+import ColumnHeader from '../Base/ColumnHeader';
+import EntityGroupHeader from '../Base/EntityGroupHeader';
+import K8sGroupCell from '../Base/K8sGroupCell';
+import { formatBytes } from '../commonUtils';
+import {
+ EntityProgressBar,
+ EntityProgressThresholds,
+ GroupedStatusCounts,
+ TextNoData,
+ ValidateColumnValueWrapper,
+} from '../components';
+import {
+ INFRA_MONITORING_ATTR_KEYS,
+ InfraMonitoringEntity,
+} from '../constants';
+import { SelectedItemParams } from '../hooks';
+import {
+ CONTAINER_READY_COLORS,
+ CONTAINER_READY_LABELS,
+ CONTAINER_STATUS_COLORS,
+ CONTAINER_STATUS_LABELS,
+ CONTAINERS_DOC_PATH,
+ getContainerImageWithTag,
+ getContainerReadyItems,
+ getContainerStatusItems,
+} from './utils';
+
+export function getK8sContainerRowKey(
+ container: InframonitoringtypesContainerRecordDTO,
+): string {
+ return (
+ [container.podUID, container.containerName].filter(Boolean).join('/') ||
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
+ ''
+ );
+}
+
+export function getK8sContainerItemKey(
+ container: InframonitoringtypesContainerRecordDTO,
+): SelectedItemParams {
+ return {
+ selectedItem:
+ container.podUID ||
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
+ null,
+ containerName:
+ container.containerName ||
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
+ null,
+ clusterName: null,
+ namespaceName: null,
+ };
+}
+
+export type ContainerTableColumnConfig =
+ TableColumnDef;
+
+/**
+ * The grouped table and its nested rows are separate tables, so a column and the
+ * one that replaces it while grouped share a width to keep the two aligned.
+ */
+const NAME_COLUMN_WIDTH = 220;
+const STATUS_COLUMN_WIDTH = 250;
+
+export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
+ {
+ id: 'containerGroup',
+ header: (): React.ReactNode => ,
+ accessorFn: (row): string => row.containerName || '',
+ width: { min: NAME_COLUMN_WIDTH },
+ enableSort: false,
+ enableRemove: false,
+ enableMove: false,
+ pin: 'left',
+ visibilityBehavior: 'hidden-on-collapse',
+ cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => (
+
+
+
+ ),
+ },
+ {
+ id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
+ header: (): React.ReactNode => (
+ }
+ docPath={`${CONTAINERS_DOC_PATH}#container-name`}
+ />
+ ),
+ accessorFn: (row): string =>
+ row.containerName ||
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
+ '',
+ width: { min: NAME_COLUMN_WIDTH },
+ enableSort: true,
+ enableRemove: false,
+ enableMove: false,
+ pin: 'left',
+ visibilityBehavior: 'hidden-on-expand',
+ cell: ({ value }): React.ReactNode => (
+ {value as string}
+ ),
+ },
+ {
+ id: 'podName',
+ header: (): React.ReactNode => (
+
+ Pod Name
+
+ ),
+ accessorFn: (row): string =>
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
+ width: { min: 260 },
+ enableSort: false,
+ cell: ({ value }): React.ReactNode => {
+ const podName = value as string;
+ if (!podName) {
+ return ;
+ }
+ return {podName};
+ },
+ },
+ {
+ id: 'namespace',
+ header: (): React.ReactNode => (
+
+ Namespace
+
+ ),
+ accessorFn: (row): string =>
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
+ width: { min: 160 },
+ enableSort: false,
+ cell: ({ value }): React.ReactNode => (
+ {value as string}
+ ),
+ },
+ {
+ id: 'image',
+ header: (): React.ReactNode => (
+
+ Image:Tag
+
+ ),
+ accessorFn: (row): string => getContainerImageWithTag(row),
+ width: { min: 240 },
+ enableSort: false,
+ cell: ({ value }): React.ReactNode => {
+ const image = value as string;
+ if (!image) {
+ return ;
+ }
+ return {image};
+ },
+ },
+ {
+ id: 'containerStatus',
+ header: (): React.ReactNode => (
+ Status
+ ),
+ accessorFn: (row): string => row.status,
+ width: { min: STATUS_COLUMN_WIDTH },
+ enableSort: false,
+ visibilityBehavior: 'hidden-on-expand',
+ cell: ({ row }): React.ReactNode => {
+ if (
+ !row.status ||
+ row.status === InframonitoringtypesContainerStatusDTO.no_data
+ ) {
+ return ;
+ }
+
+ return (
+
+ {CONTAINER_STATUS_LABELS[row.status]}
+
+ );
+ },
+ },
+ {
+ id: 'containerCountsByStatus',
+ header: (): React.ReactNode => (
+ Status
+ ),
+ accessorFn: (
+ row,
+ ): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] =>
+ row.containerCountsByStatus,
+ width: { min: STATUS_COLUMN_WIDTH },
+ enableSort: false,
+ visibilityBehavior: 'hidden-on-collapse',
+ cell: ({ row, rowId }): React.ReactNode => {
+ if (!row.containerCountsByStatus) {
+ return ;
+ }
+ return (
+
+ );
+ },
+ },
+ {
+ id: 'containerReady',
+ header: (): React.ReactNode => (
+ Ready
+ ),
+ accessorFn: (row): string => row.ready,
+ width: { min: 130 },
+ enableSort: false,
+ visibilityBehavior: 'hidden-on-expand',
+ cell: ({ row }): React.ReactNode => {
+ if (
+ !row.ready ||
+ row.ready === InframonitoringtypesContainerReadyDTO.no_data
+ ) {
+ return ;
+ }
+
+ return (
+
+ {CONTAINER_READY_LABELS[row.ready]}
+
+ );
+ },
+ },
+ {
+ id: 'containerCountsByReady',
+ header: (): React.ReactNode => (
+ Ready
+ ),
+ accessorFn: (
+ row,
+ ): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
+ row.containerCountsByReady,
+ width: { min: 130 },
+ enableSort: false,
+ visibilityBehavior: 'hidden-on-collapse',
+ cell: ({ row, rowId }): React.ReactNode => {
+ if (!row.containerCountsByReady) {
+ return ;
+ }
+ return (
+
+ );
+ },
+ },
+ {
+ id: 'containerRestarts',
+ header: (): React.ReactNode => (
+
+ Restarts
+
+ ),
+ accessorFn: (row): number => row.restarts,
+ width: { min: 130 },
+ enableSort: false,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+ {value as number}
+
+ ),
+ },
+ {
+ id: 'cpu_request',
+ header: (): React.ReactNode => (
+ }
+ >
+ CPU Request Usage (%)
+
+ ),
+ accessorFn: (row): number => row.cpuRequestUtilization,
+ width: { min: 210 },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+
+
+ ),
+ },
+ {
+ id: 'cpu_limit',
+ header: (): React.ReactNode => (
+ }
+ >
+ CPU Limit Usage (%)
+
+ ),
+ accessorFn: (row): number => row.cpuLimitUtilization,
+ width: { min: 220 },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+
+
+ ),
+ },
+ {
+ id: 'cpu',
+ header: (): React.ReactNode => (
+
+ CPU Usage (cores)
+
+ ),
+ accessorFn: (row): number => row.cpu,
+ width: { min: 160 },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+ {Number(value).toFixed(2)}
+
+ ),
+ },
+ {
+ id: 'memory_request',
+ header: (): React.ReactNode => (
+ }
+ >
+ Memory Request Usage (%)
+
+ ),
+ accessorFn: (row): number => row.memoryRequestUtilization,
+ width: { min: 210 },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+
+
+ ),
+ },
+ {
+ id: 'memory_limit',
+ header: (): React.ReactNode => (
+ }
+ >
+ Memory Limit Usage (%)
+
+ ),
+ accessorFn: (row): number => row.memoryLimitUtilization,
+ width: { min: 220 },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+
+
+ ),
+ },
+ {
+ id: 'memory',
+ header: (): React.ReactNode => (
+
+ Memory Usage (WSS)
+
+ ),
+ accessorFn: (row): number => row.memory,
+ width: { min: 210, default: '100%' },
+ enableSort: true,
+ cell: ({ value, rowId }): React.ReactNode => (
+
+ {formatBytes(value as number)}
+
+ ),
+ },
+ {
+ id: 'node',
+ header: (): React.ReactNode => (
+
+ Node
+
+ ),
+ accessorFn: (row): string =>
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
+ width: { default: 100 },
+ enableSort: false,
+ defaultVisibility: false,
+ cell: ({ value }): React.ReactNode => (
+ {value as string}
+ ),
+ },
+ {
+ id: 'cluster',
+ header: (): React.ReactNode => (
+
+ Cluster
+
+ ),
+ accessorFn: (row): string =>
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
+ width: { default: 100 },
+ enableSort: false,
+ defaultVisibility: false,
+ cell: ({ value }): React.ReactNode => (
+ {value as string}
+ ),
+ },
+ {
+ id: 'deployment',
+ header: (): React.ReactNode => (
+
+ Deployment
+
+ ),
+ accessorFn: (row): string =>
+ row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
+ width: { default: 100 },
+ enableSort: false,
+ defaultVisibility: false,
+ cell: ({ value }): React.ReactNode => (
+ {value as string}
+ ),
+ },
+];
diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
new file mode 100644
index 00000000000..ce2dfa0cd6d
--- /dev/null
+++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
@@ -0,0 +1,169 @@
+import { Color } from '@signozhq/design-tokens';
+import { BadgeColor } from '@signozhq/ui/badge';
+import {
+ InframonitoringtypesContainerCountsByReadyDTO,
+ InframonitoringtypesContainerCountsByStatusDTO,
+ InframonitoringtypesContainerReadyDTO,
+ InframonitoringtypesContainerRecordDTO,
+ InframonitoringtypesContainerStatusDTO,
+} from 'api/generated/services/sigNoz.schemas';
+
+import { StatusCountItem } from '../components/GroupedStatusCounts';
+import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
+
+export const CONTAINERS_DOC_PATH =
+ '/infrastructure-monitoring/kubernetes/containers';
+
+/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */
+export function getContainerImageWithTag(
+ container: InframonitoringtypesContainerRecordDTO,
+): string {
+ const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME];
+ const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG];
+
+ if (!name) {
+ return '';
+ }
+
+ return tag ? `${name}:${tag}` : name;
+}
+
+export function getContainerName(
+ container: InframonitoringtypesContainerRecordDTO,
+): string {
+ return (
+ container.containerName ||
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
+ ''
+ );
+}
+
+export function getContainerPodUID(
+ container: InframonitoringtypesContainerRecordDTO,
+): string {
+ return (
+ container.podUID ||
+ container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
+ ''
+ );
+}
+
+export const CONTAINER_STATUS_COLORS: Record<
+ InframonitoringtypesContainerStatusDTO,
+ BadgeColor
+> = {
+ [InframonitoringtypesContainerStatusDTO.running]: 'forest',
+ [InframonitoringtypesContainerStatusDTO.completed]: 'robin',
+ [InframonitoringtypesContainerStatusDTO.waiting]: 'amber',
+ [InframonitoringtypesContainerStatusDTO.containercreating]: 'amber',
+ [InframonitoringtypesContainerStatusDTO.terminated]: 'sienna',
+ [InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla',
+ [InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla',
+ [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.error]: 'cherry',
+ [InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry',
+};
+
+/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
+export const CONTAINER_STATUS_LABELS: Record<
+ InframonitoringtypesContainerStatusDTO,
+ string
+> = {
+ [InframonitoringtypesContainerStatusDTO.running]: 'Running',
+ [InframonitoringtypesContainerStatusDTO.completed]: 'Completed',
+ [InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting',
+ [InframonitoringtypesContainerStatusDTO.containercreating]:
+ 'ContainerCreating',
+ [InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated',
+ [InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown',
+ [InframonitoringtypesContainerStatusDTO.no_data]: 'No data',
+ [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
+ [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
+ [InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull',
+ [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]:
+ 'CreateContainerConfigError',
+ [InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled',
+ [InframonitoringtypesContainerStatusDTO.error]: 'Error',
+ [InframonitoringtypesContainerStatusDTO.containercannotrun]:
+ 'ContainerCannotRun',
+};
+
+const CONTAINER_ERROR_STATUS_LABELS: Partial<
+ Record
+> = {
+ crashLoopBackOff: 'CrashLoopBackOff',
+ imagePullBackOff: 'ImagePullBackOff',
+ errImagePull: 'ErrImagePull',
+ createContainerConfigError: 'CreateContainerConfigError',
+ oomKilled: 'OOMKilled',
+ error: 'Error',
+ containerCannotRun: 'ContainerCannotRun',
+};
+
+export function getContainerStatusItems(
+ counts: InframonitoringtypesContainerCountsByStatusDTO,
+): StatusCountItem[] {
+ const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array<
+ keyof typeof CONTAINER_ERROR_STATUS_LABELS
+ >;
+
+ return [
+ { value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
+ { value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
+ {
+ value: counts.waiting + counts.containerCreating,
+ label: 'Waiting',
+ color: Color.BG_AMBER_500,
+ breakdown: [
+ { label: 'Waiting', value: counts.waiting },
+ { label: 'ContainerCreating', value: counts.containerCreating },
+ ],
+ },
+ {
+ value: counts.terminated,
+ label: 'Terminated',
+ color: Color.BG_SIENNA_500,
+ },
+ { value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
+ {
+ value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
+ label: 'Error Status',
+ color: Color.BG_CHERRY_500,
+ breakdown: errorKeys.map((key) => ({
+ label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
+ value: counts[key],
+ })),
+ },
+ ];
+}
+
+export const CONTAINER_READY_COLORS: Record<
+ InframonitoringtypesContainerReadyDTO,
+ BadgeColor
+> = {
+ [InframonitoringtypesContainerReadyDTO.ready]: 'forest',
+ [InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry',
+ [InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla',
+};
+
+export const CONTAINER_READY_LABELS: Record<
+ InframonitoringtypesContainerReadyDTO,
+ string
+> = {
+ [InframonitoringtypesContainerReadyDTO.ready]: 'Ready',
+ [InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready',
+ [InframonitoringtypesContainerReadyDTO.no_data]: 'No data',
+};
+
+export function getContainerReadyItems(
+ counts: InframonitoringtypesContainerCountsByReadyDTO,
+): StatusCountItem[] {
+ return [
+ { value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 },
+ { value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 },
+ ];
+}
diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx
index acb38f19029..4ede4924307 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx
+++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx
@@ -3,8 +3,9 @@ import { Compass, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ChartHeader.module.scss';
+import { DOCS_BASE_URL } from 'constants/app';
-const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
+const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
interface ChartHeaderProps {
title: string;
@@ -33,7 +34,7 @@ function ChartHeader({
<>
{tooltipTitle}{' '}
e.stopPropagation()}
diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss
index be86b269387..355ccf47a23 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss
+++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss
@@ -37,38 +37,28 @@
.title {
font-family: var(--periscope-font-family-mono);
- --typography-margin: 0px var(--spacing-4) 0px 0px;
+ --typography-margin: 0px var(--spacing-1) 0px 0px;
}
-.entityDetailsEntity {
- display: flex;
- flex-direction: column;
+// Sized to the icon rather than the default tap target, so it sits beside the
+// entity name instead of a gap away from it.
+.copyIdButton {
+ --button-padding: 2px;
+ --button-height: auto;
+ --button-width: auto;
}
-.entityDetailsGrid {
+.entityDetailsEntity {
display: flex;
flex-direction: column;
}
-.labelsRow,
-.valuesRow {
+// Tracks size to the entity's field count instead of a fixed four, so entities
+// with more fields stay on one row rather than spilling a near-empty second one.
+.entityDetailsGrid {
display: grid;
- grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
- gap: 30px;
- align-items: center;
-}
-
-.labelsRow {
- margin-bottom: var(--spacing-4);
-}
-
-.entityDetailsMetadataLabel {
- letter-spacing: 0.44px;
- text-transform: uppercase;
-}
-
-.entityDetailsMetadataValue {
- font-family: var(--periscope-font-family-mono);
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--spacing-4) var(--spacing-6);
}
.viewsTabsContainer {
diff --git a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx
index 94e3b9fada1..4d402d53610 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx
+++ b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx
@@ -16,6 +16,7 @@ import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
+ Box,
Boxes,
Computer,
Container,
@@ -31,6 +32,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
+ GetContainersQuickFiltersConfig,
GetDaemonsetsQuickFiltersConfig,
GetDeploymentsQuickFiltersConfig,
GetJobsQuickFiltersConfig,
@@ -152,6 +154,12 @@ export default function InfraMonitoringK8s(): JSX.Element {
const categories = useMemo(
() => [
+ {
+ key: K8sCategories.CONTAINERS,
+ label: 'Containers',
+ icon: ,
+ config: GetContainersQuickFiltersConfig(),
+ },
{
key: K8sCategories.PODS,
label: 'Pods',
diff --git a/frontend/src/container/InfraMonitoringK8sV2/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/constants.ts
index f4c4a5236cd..d3aae6489dc 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/constants.ts
+++ b/frontend/src/container/InfraMonitoringK8sV2/constants.ts
@@ -61,10 +61,26 @@ export const INFRA_MONITORING_ATTR_KEYS = {
K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit',
K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request',
K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit',
+ K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization',
+ K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization',
+ K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION:
+ 'k8s.container.memory_request_utilization',
+ K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION:
+ 'k8s.container.memory_limit_utilization',
+ K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization',
+ K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization',
CONTAINER_CPU_USAGE: 'container.cpu.usage',
CONTAINER_MEMORY_USAGE: 'container.memory.usage',
+ CONTAINER_MEMORY_AVAILABLE: 'container.memory.available',
CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set',
CONTAINER_MEMORY_RSS: 'container.memory.rss',
+ CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults',
+ CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available',
+ CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity',
+ CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage',
+ CONTAINER_UPTIME: 'container.uptime',
+ CONTAINER_IMAGE_NAME: 'container.image.name',
+ CONTAINER_IMAGE_TAG: 'container.image.tag',
// Deployment
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
@@ -165,6 +181,9 @@ export const K8sCategories = {
VOLUMES: 'volumes',
};
+/** The section the Kubernetes view opens on when a link names none. */
+export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS;
+
const dotMap = {
[InfraMonitoringEntity.HOSTS]:
INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
@@ -181,7 +200,7 @@ const dotMap = {
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
[InfraMonitoringEntity.CONTAINERS]:
- INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
+ INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
[InfraMonitoringEntity.JOBS]:
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS,
[InfraMonitoringEntity.VOLUMES]:
@@ -321,6 +340,161 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
];
}
+export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
+ return [
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Container',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: true,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Pod',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: true,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Namespace',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Node',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Cluster',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Image',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Deployment',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Statefulset',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'DaemonSet',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Job',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
+ dataType: DataTypes.String,
+ type: 'resource',
+ id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`,
+ },
+ aggregateOperator: 'noop',
+ aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
+ dataSource: DataSource.METRICS,
+ defaultOpen: false,
+ },
+ {
+ type: FiltersType.CHECKBOX,
+ title: 'Environment',
+ attributeKey: {
+ key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
+ dataType: DataTypes.String,
+ type: 'resource',
+ },
+ defaultOpen: true,
+ },
+ ];
+}
+
export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
return [
{
@@ -767,6 +941,7 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
+ SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',
@@ -783,7 +958,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record =
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
- [InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
+ [InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
[InfraMonitoringEntity.JOBS]: 'k8s.',
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
};
diff --git a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts
index 33e4bbd0ba1..80ad35c3023 100644
--- a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts
+++ b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts
@@ -16,8 +16,8 @@ import {
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
+ DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
- K8sCategories,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -130,19 +130,23 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY,
- parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
+ parseAsString
+ .withDefault(DEFAULT_K8S_CATEGORY)
+ .withOptions({ ...defaultNuqsOptions, clearOnDefault: false }),
);
export interface SelectedItemParams {
selectedItem: string | null;
clusterName?: string | null;
namespaceName?: string | null;
+ containerName?: string | null;
}
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
+ [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
};
export type UseSelectedItemParamsReturn = [
@@ -167,6 +171,9 @@ export const useInfraMonitoringSelectedItemParams =
namespaceName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
null,
+ containerName:
+ rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ??
+ null,
}),
[rawParams],
);
@@ -178,6 +185,7 @@ export const useInfraMonitoringSelectedItemParams =
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
+ [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
});
return;
}
@@ -189,6 +197,8 @@ export const useInfraMonitoringSelectedItemParams =
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
newParams.namespaceName ?? null,
+ [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]:
+ newParams.containerName ?? null,
});
},
[setRawParams],
diff --git a/frontend/src/pages/InfrastructureMonitoring/constants.tsx b/frontend/src/pages/InfrastructureMonitoring/constants.tsx
index 82c098a5d8f..dfb5efafa5c 100644
--- a/frontend/src/pages/InfrastructureMonitoring/constants.tsx
+++ b/frontend/src/pages/InfrastructureMonitoring/constants.tsx
@@ -2,6 +2,10 @@ import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
+import {
+ DEFAULT_K8S_CATEGORY,
+ INFRA_MONITORING_K8S_PARAMS_KEYS,
+} from 'container/InfraMonitoringK8sV2/constants';
import { Inbox } from '@signozhq/icons';
function HostsContainer(): JSX.Element {
@@ -30,6 +34,6 @@ export const Kubernetes: TabRoutes = {
Kubernetes
),
- route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
+ route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`,
key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
};
diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira.go b/pkg/alertmanager/alertmanagernotify/jira/jira.go
new file mode 100644
index 00000000000..638dfacb4f7
--- /dev/null
+++ b/pkg/alertmanager/alertmanagernotify/jira/jira.go
@@ -0,0 +1,557 @@
+// Copyright (c) 2026 SigNoz, Inc.
+// Copyright 2023 Prometheus Team
+// SPDX-License-Identifier: Apache-2.0
+
+package jira
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf16"
+
+ "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
+ "github.com/SigNoz/signoz/pkg/errors"
+ "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf"
+ "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
+ "github.com/SigNoz/signoz/pkg/types/ruletypes"
+ "github.com/prometheus/alertmanager/notify"
+ "github.com/prometheus/alertmanager/template"
+ "github.com/prometheus/alertmanager/types"
+)
+
+const Integration = "jira"
+
+const (
+ maxSummaryLenRunes = 255
+ maxDescriptionLenRunes = 32767
+)
+
+// Notifier implements notify.Notifier for Jira.
+type Notifier struct {
+ conf *alertmanagertypes.JiraReceiverConfig
+ logger *slog.Logger
+ client *http.Client
+ retrier *notify.Retrier
+ templater alertmanagertypes.Templater
+}
+
+func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
+ if conf.HTTPConfig == nil {
+ return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil")
+ }
+ client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
+ if err != nil {
+ return nil, err
+ }
+ return &Notifier{
+ conf: conf,
+ logger: l,
+ client: client,
+ retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
+ templater: templater,
+ }, nil
+}
+
+func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
+ key, err := notify.ExtractGroupKey(ctx)
+ if err != nil {
+ return false, err
+ }
+ groupID := key.Hash()
+ firing := types.Alerts(as...).HasFiring()
+ n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing))
+
+ customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
+ result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
+ TitleTemplate: customTitle,
+ BodyTemplate: customBody,
+ DefaultTitleTemplate: n.conf.Summary,
+ DefaultBodyTemplate: n.conf.Description,
+ }, as)
+ if err != nil {
+ return false, err
+ }
+ summary := truncateRunes(result.Title, maxSummaryLenRunes)
+
+ var parts []string
+ for _, body := range result.Body {
+ if body != "" {
+ parts = append(parts, body)
+ }
+ }
+ // custom body templates render per alert; join them under ADF rule dividers.
+ // The default body is a single combined part, so the join is a no-op there.
+ descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes)
+
+ baseURL, retry, err := n.resolveAPIBaseURL(ctx)
+ if err != nil {
+ return retry, err
+ }
+
+ existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing)
+ if err != nil {
+ return retry, err
+ }
+
+ fields := n.buildFields(groupID, summary, descText, as, firing)
+
+ // No existing issue: create for firing groups; never create for resolved-only.
+ if existing == nil {
+ if !firing {
+ return false, nil
+ }
+ return n.createIssue(ctx, baseURL, fields)
+ }
+
+ // Existing issue: refresh it, then transition + comment based on the new state.
+ if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil {
+ return retry, err
+ }
+
+ // Each state-change comment carries the same rich snapshot as the description
+ // (panel + details + deep-links), so the comment timeline mirrors the card
+ // Google Chat re-posts on every notification.
+ switch {
+ case firing && existing.isDone(): // re-fired after resolution → reopen
+ if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil {
+ return retry, err
+ }
+ case !firing: // resolved (search returns only open issues, so this one is open)
+ if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil {
+ return retry, err
+ }
+ }
+ // firing && !isDone (still firing) needs no transition.
+ return n.addComment(ctx, baseURL, existing.Key, fields.Description)
+}
+
+func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields {
+ f := &issueFields{
+ Project: &idKey{Key: n.conf.Project},
+ Issuetype: &idName{Name: n.conf.IssueType},
+ Summary: summary,
+ Labels: n.labels(groupID),
+ Description: n.buildBoundedDescription(descText, alerts, firing),
+ }
+ if n.conf.Priority != "" {
+ f.Priority = &idName{Name: n.conf.Priority}
+ }
+ return f
+}
+
+// buildBoundedDescription builds the ADF issue body and keeps it within Jira's
+// description limit, which counts text characters plus per-node overhead — so a
+// text-only markdown cap is not enough. Over-limit bodies are shrunk at the
+// markdown level and rebuilt; the panel and deep-links are part of the measured
+// document, so the result always fits.
+func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
+ doc := n.buildDescription(descText, alerts, firing)
+ for range 4 {
+ size := adfDocLen(doc)
+ if size <= maxDescriptionLenRunes {
+ return doc
+ }
+ runes := []rune(descText)
+ keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10
+ if keep >= len(runes) {
+ keep = len(runes) - 1
+ }
+ if keep <= 0 {
+ break
+ }
+ descText = string(runes[:keep]) + "…"
+ doc = n.buildDescription(descText, alerts, firing)
+ }
+ if adfDocLen(doc) <= maxDescriptionLenRunes {
+ return doc
+ }
+ // still over after shrinking: keep just the panel and deep-links
+ return n.buildDescription("", alerts, firing)
+}
+
+// adfDocLen approximates how Jira measures an ADF document against the 32767
+// limit: text length in UTF-16 code units, plus per-node overhead (block
+// boundaries count like newlines), plus link targets. Deliberately counts on
+// the high side so a passing measurement never 400s.
+func adfDocLen(node any) int {
+ m, ok := node.(map[string]any)
+ if !ok {
+ return 0
+ }
+ size := 2
+ if text, ok := m["text"].(string); ok {
+ for _, r := range text {
+ size += utf16.RuneLen(r)
+ }
+ }
+ if marks, ok := m["marks"].([]any); ok {
+ for _, mark := range marks {
+ if mm, ok := mark.(map[string]any); ok {
+ if attrs, ok := mm["attrs"].(map[string]any); ok {
+ if href, ok := attrs["href"].(string); ok {
+ size += len(href)
+ }
+ }
+ }
+ }
+ }
+ if content, ok := m["content"].([]any); ok {
+ for _, child := range content {
+ size += adfDocLen(child)
+ }
+ }
+ return size
+}
+
+// buildDescription assembles the ADF issue body: a firing/resolved status panel,
+// the rendered markdown body, and SigNoz deep-links.
+func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
+ content := []any{statusPanel(firing)}
+ content = append(content, adf.Render(descText)...)
+ if links := deepLinks(alerts); links != nil {
+ content = append(content, links)
+ }
+ return map[string]any{"type": "doc", "version": 1, "content": content}
+}
+
+func statusPanel(firing bool) map[string]any {
+ panelType, label := "success", "🟢 RESOLVED"
+ if firing {
+ panelType, label = "error", "🔴 FIRING"
+ }
+ return map[string]any{
+ "type": "panel",
+ "attrs": map[string]any{"panelType": panelType},
+ "content": []any{map[string]any{
+ "type": "paragraph",
+ "content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}},
+ }},
+ }
+}
+
+// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label
+// and the related-logs/traces annotations. Returns nil when none are present.
+func deepLinks(alerts []*types.Alert) map[string]any {
+ if len(alerts) == 0 {
+ return nil
+ }
+ a := alerts[0]
+ var parts []any
+ add := func(label, url string) {
+ if url == "" {
+ return
+ }
+ if len(parts) > 0 {
+ parts = append(parts, map[string]any{"type": "text", "text": " · "})
+ }
+ parts = append(parts, map[string]any{
+ "type": "text",
+ "text": label,
+ "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}},
+ })
+ }
+ add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
+ add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
+ add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
+ if len(parts) == 0 {
+ return nil
+ }
+ return map[string]any{"type": "paragraph", "content": parts}
+}
+
+func (n *Notifier) labels(groupID string) []string {
+ out := append([]string{}, n.conf.Labels...)
+ out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID))
+ sort.Strings(out)
+ return out
+}
+
+func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) {
+ var jql strings.Builder
+ if n.conf.WontFixResolution != "" {
+ // != alone also drops unresolved (EMPTY) issues, so keep those explicitly.
+ fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution)
+ }
+ if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 {
+ fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin)
+ } else {
+ jql.WriteString(`statusCategory != Done and `)
+ }
+ fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID))
+
+ body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{
+ JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"},
+ })
+ if err != nil {
+ return nil, retry, err
+ }
+ var res searchResult
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, false, err
+ }
+ if len(res.Issues) == 0 {
+ return nil, false, nil
+ }
+ // the JQL order is not category-aware, so prefer an open issue over a done
+ // one; all done falls back to the most recently resolved (resolutiondate DESC)
+ for i := range res.Issues {
+ if !res.Issues[i].isDone() {
+ return &res.Issues[i], false, nil
+ }
+ }
+ return &res.Issues[0], false, nil
+}
+
+func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) {
+ _, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields})
+ return retry, err
+}
+
+func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) {
+ // project and issue type are set at creation and cannot be edited.
+ upd := *fields
+ upd.Project = nil
+ upd.Issuetype = nil
+ // Jira replaces the labels array wholesale, so union in the labels already
+ // on the issue to keep user-added ones.
+ if existing.Fields != nil {
+ upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels)
+ }
+ _, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd})
+ return retry, err
+}
+
+func mergeLabels(existing, ours []string) []string {
+ seen := make(map[string]bool, len(existing)+len(ours))
+ var merged []string
+ for _, label := range append(append([]string{}, existing...), ours...) {
+ if !seen[label] {
+ seen[label] = true
+ merged = append(merged, label)
+ }
+ }
+ sort.Strings(merged)
+ return merged
+}
+
+// applyTransition moves the issue into (toDone) or out of (!toDone) the "done"
+// status category, preferring the named override, else the first matching
+// transition, else skipping without error when none is available.
+func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) {
+ transitions, retry, err := n.getTransitions(ctx, baseURL, key)
+ if err != nil {
+ return retry, err
+ }
+ id := selectTransition(transitions, toDone, override)
+ if id == "" {
+ n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone))
+ return false, nil
+ }
+ _, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}})
+ return retry, err
+}
+
+func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) {
+ body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil)
+ if err != nil {
+ return nil, retry, err
+ }
+ var tr transitionsResponse
+ if err := json.Unmarshal(body, &tr); err != nil {
+ return nil, false, err
+ }
+ return tr.Transitions, false, nil
+}
+
+func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) {
+ _, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body})
+ return retry, err
+}
+
+func (n *Notifier) issueURL(baseURL, key, sub string) string {
+ u := baseURL + "/issue/" + key
+ if sub != "" {
+ u += "/" + sub
+ }
+ return u
+}
+
+func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) {
+ var body io.Reader
+ if reqBody != nil {
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
+ return nil, false, err
+ }
+ body = &buf
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, false, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body
+ if err != nil {
+ return nil, true, notify.RedactURL(err)
+ }
+ defer notify.Drain(resp)
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, false, err
+ }
+ shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody))
+ if err != nil {
+ return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
+ }
+ return respBody, false, nil
+}
+
+// resolveAPIBaseURL resolves the service-account cloud id per notification (it
+// is never persisted); personal API tokens use the site host directly.
+func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) {
+ if !n.conf.IsServiceAccount() {
+ return n.conf.APIBaseURL(""), false, nil
+ }
+ cloudID, retry, err := n.resolveCloudID(ctx)
+ if err != nil {
+ return "", retry, err
+ }
+ return n.conf.APIBaseURL(cloudID), false, nil
+}
+
+// resolveCloudID fetches the site's cloud id from its unauthenticated
+// tenant_info endpoint; transport failures are retryable, bad responses are not.
+func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) {
+ url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return "", false, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := n.client.Do(req)
+ if err != nil {
+ return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id")
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", true, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode)
+ }
+
+ var out struct {
+ CloudID string `json:"cloudId"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response")
+ }
+ if out.CloudID == "" {
+ return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site)
+ }
+ return out.CloudID, false, nil
+}
+
+// selectTransition returns the id of the transition whose target status category
+// matches toDone, preferring one named override when present.
+func selectTransition(transitions []jiraTransition, toDone bool, override string) string {
+ if override != "" {
+ for _, t := range transitions {
+ if t.Name == override {
+ return t.ID
+ }
+ }
+ }
+ for _, t := range transitions {
+ if (t.To.StatusCategory.Key == "done") == toDone {
+ return t.ID
+ }
+ }
+ return ""
+}
+
+// Jira API types.
+type issue struct {
+ Key string `json:"key,omitempty"`
+ Fields *issueFields `json:"fields,omitempty"`
+ Transition *idName `json:"transition,omitempty"`
+}
+
+type issueFields struct {
+ Project *idKey `json:"project,omitempty"`
+ Issuetype *idName `json:"issuetype,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Labels []string `json:"labels,omitempty"`
+ Priority *idName `json:"priority,omitempty"`
+ Description any `json:"description,omitempty"`
+ Status *issueStatus `json:"status,omitempty"`
+}
+
+type idKey struct {
+ Key string `json:"key"`
+}
+
+type idName struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+type issueStatus struct {
+ StatusCategory struct {
+ Key string `json:"key"`
+ } `json:"statusCategory"`
+}
+
+func (i *issue) isDone() bool {
+ return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done"
+}
+
+type searchRequest struct {
+ JQL string `json:"jql"`
+ MaxResults int `json:"maxResults"`
+ Fields []string `json:"fields"`
+}
+
+type searchResult struct {
+ Issues []issue `json:"issues"`
+}
+
+type transitionsResponse struct {
+ Transitions []jiraTransition `json:"transitions"`
+}
+
+type jiraTransition struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ To struct {
+ StatusCategory struct {
+ Key string `json:"key"`
+ } `json:"statusCategory"`
+ } `json:"to"`
+}
+
+type comment struct {
+ Body any `json:"body"`
+}
+
+func truncateRunes(s string, max int) string {
+ r := []rune(s)
+ if len(r) <= max {
+ return s
+ }
+ return string(r[:max])
+}
diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira_test.go b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go
new file mode 100644
index 00000000000..042efbbdd34
--- /dev/null
+++ b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go
@@ -0,0 +1,489 @@
+package jira
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
+ "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
+ "github.com/SigNoz/signoz/pkg/types/ruletypes"
+ "github.com/prometheus/alertmanager/notify"
+ "github.com/prometheus/alertmanager/notify/test"
+ "github.com/prometheus/alertmanager/types"
+ commoncfg "github.com/prometheus/common/config"
+ "github.com/prometheus/common/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockReq struct {
+ method string
+ path string
+ body map[string]any
+}
+
+type mockJira struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ reqs []mockReq
+ searchIssues []issue
+ transitions []jiraTransition
+ createStatus int
+}
+
+func newMockJira(t *testing.T) *mockJira {
+ t.Helper()
+ m := &mockJira{}
+ m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
+ t.Cleanup(m.srv.Close)
+ return m
+}
+
+func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ m.mu.Lock()
+ m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body})
+ m.mu.Unlock()
+
+ p := r.URL.Path
+ switch {
+ case strings.HasSuffix(p, "/search/jql"):
+ _ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues})
+ case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet:
+ _ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions})
+ case strings.HasSuffix(p, "/transitions"):
+ w.WriteHeader(http.StatusNoContent)
+ case strings.HasSuffix(p, "/comment"):
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"id":"1"}`))
+ case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost:
+ st := m.createStatus
+ if st == 0 {
+ st = http.StatusCreated
+ }
+ w.WriteHeader(st)
+ _, _ = w.Write([]byte(`{"key":"KAN-1"}`))
+ case r.Method == http.MethodPut:
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
+func (m *mockJira) countPost(suffix string) int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := 0
+ for _, r := range m.reqs {
+ if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) {
+ c++
+ }
+ }
+ return c
+}
+
+func (m *mockJira) countPuts() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := 0
+ for _, r := range m.reqs {
+ if r.method == http.MethodPut {
+ c++
+ }
+ }
+ return c
+}
+
+func newNotifier(t *testing.T, m *mockJira) *Notifier {
+ t.Helper()
+ tmpl := test.CreateTmpl(t)
+ n, err := New(&alertmanagertypes.JiraReceiverConfig{
+ Site: m.srv.URL,
+ Project: "KAN",
+ IssueType: "Task",
+ Summary: alertmanagertypes.DefaultJiraSummaryTemplate,
+ Description: alertmanagertypes.DefaultJiraDescriptionTemplate,
+ HTTPConfig: &commoncfg.HTTPClientConfig{},
+ ReopenDuration: model.Duration(3 * 24 * time.Hour),
+ }, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler)))
+ require.NoError(t, err)
+ return n
+}
+
+func alert(firing bool) *types.Alert {
+ a := &types.Alert{Alert: model.Alert{
+ Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"},
+ Annotations: model.LabelSet{"summary": "cpu high"},
+ StartsAt: time.Now().Add(-time.Minute),
+ }}
+ if firing {
+ a.EndsAt = time.Now().Add(time.Hour)
+ } else {
+ a.EndsAt = time.Now().Add(-time.Minute)
+ }
+ return a
+}
+
+func ctx() context.Context {
+ return notify.WithGroupKey(context.Background(), "test-jira")
+}
+
+func doneIssue() issue {
+ i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
+ i.Fields.Status.StatusCategory.Key = "done"
+ return i
+}
+
+func openIssue() issue {
+ i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
+ i.Fields.Status.StatusCategory.Key = "new"
+ return i
+}
+
+func transition(id, name, category string) jiraTransition {
+ tr := jiraTransition{ID: id, Name: name}
+ tr.To.StatusCategory.Key = category
+ return tr
+}
+
+func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) {
+ m := newMockJira(t)
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 1, m.countPost("/issue"))
+ assert.Equal(t, 0, m.countPost("/comment")) // no comment on create
+ assert.Equal(t, 0, m.countPuts()) // no update
+}
+
+func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) {
+ m := newMockJira(t)
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 1, m.countPost("/search/jql"))
+ assert.Equal(t, 0, m.countPost("/issue"))
+}
+
+func TestNotifyStillFiringUpdatesAndComments(t *testing.T) {
+ m := newMockJira(t)
+ m.searchIssues = []issue{openIssue()}
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 0, m.countPost("/issue")) // no create
+ assert.Equal(t, 1, m.countPuts()) // update
+ assert.Equal(t, 1, m.countPost("/comment"))
+ assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition
+
+ // comment carries the full rich snapshot (panel + labeled body), not a one-liner.
+ cjs, err := json.Marshal(m.lastBody(t, "/comment"))
+ require.NoError(t, err)
+ assert.Contains(t, string(cjs), `"panel"`)
+ assert.Contains(t, string(cjs), "Summary:")
+}
+
+func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) {
+ m := newMockJira(t)
+ m.searchIssues = []issue{openIssue()}
+ m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 1, m.countPuts()) // update
+ assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition
+ assert.Equal(t, 1, m.countPost("/comment"))
+}
+
+func TestNotifyReopensDoneIssue(t *testing.T) {
+ m := newMockJira(t)
+ m.searchIssues = []issue{doneIssue()}
+ m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition
+ assert.Equal(t, 1, m.countPost("/comment"))
+}
+
+func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) {
+ m := newMockJira(t)
+ m.searchIssues = []issue{openIssue()}
+ m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
+ require.NoError(t, err) // must not error
+ assert.False(t, retry)
+ assert.Equal(t, 0, m.countPost("/transitions")) // skipped
+ assert.Equal(t, 1, m.countPost("/comment")) // comment still posted
+}
+
+func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) {
+ m := newMockJira(t)
+ open := openIssue()
+ open.Key = "KAN-2"
+ // the JQL order can put a recently-done issue first; the open one must win
+ m.searchIssues = []issue{doneIssue(), open}
+
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+ assert.False(t, retry)
+ assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create
+ assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen
+ assert.Equal(t, 1, m.countPuts())
+ assert.Equal(t, 1, m.countPost("/comment"))
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, r := range m.reqs {
+ if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") {
+ assert.Contains(t, r.path, "KAN-2")
+ }
+ }
+}
+
+func TestNotifyRetriesOn429(t *testing.T) {
+ m := newMockJira(t)
+ m.createStatus = http.StatusTooManyRequests
+ retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.Error(t, err)
+ assert.True(t, retry)
+}
+
+func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any {
+ t.Helper()
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for i := len(m.reqs) - 1; i >= 0; i-- {
+ if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) {
+ return m.reqs[i].body
+ }
+ }
+ t.Fatalf("no POST request to %s", suffix)
+ return nil
+}
+
+func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
+ m := newMockJira(t)
+ a := alert(true)
+ a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
+ a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
+
+ _, err := newNotifier(t, m).Notify(ctx(), a)
+ require.NoError(t, err)
+
+ body := m.lastBody(t, "/issue")
+ js, err := json.Marshal(body)
+ require.NoError(t, err)
+ s := string(js)
+ assert.Contains(t, s, `"panel"`) // status panel present
+ assert.Contains(t, s, `"error"`) // firing → error panel
+ assert.Contains(t, s, "Open in SigNoz") // rule deep-link
+ assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url
+ assert.Contains(t, s, "View Related Logs") // related-logs deep-link
+ assert.Contains(t, s, "Summary:") // labeled body section
+ assert.Contains(t, s, "cpu high") // rendered annotation
+}
+
+func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) {
+ m := newMockJira(t)
+ a1 := alert(true)
+ a1.Labels["service"] = "payment"
+ a1.Labels["namespace"] = "ns-one"
+ a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
+ a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
+ a2 := alert(true)
+ a2.Labels["service"] = "payment"
+ a2.Labels["namespace"] = "ns-two"
+ a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
+ a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
+
+ _, err := newNotifier(t, m).Notify(ctx(), a1, a2)
+ require.NoError(t, err)
+
+ body := m.lastBody(t, "/issue")
+ fields, ok := body["fields"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "High throughput for payment", fields["summary"])
+
+ js, err := json.Marshal(fields["description"])
+ require.NoError(t, err)
+ s := string(js)
+ assert.Contains(t, s, "Firing in NS: ns-one")
+ assert.Contains(t, s, "Firing in NS: ns-two")
+ // per-alert custom bodies are separated by an ADF rule divider
+ assert.Contains(t, s, `"rule"`)
+ assert.NotContains(t, s, "Summary:") // default body template not used
+}
+
+// Jira replaces labels wholesale on PUT, so the update must union in the
+// labels already on the issue or user-added ones get wiped.
+func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) {
+ m := newMockJira(t)
+ existing := openIssue()
+ existing.Fields.Labels = []string{"user-added-label", "signoz-alert"}
+ m.searchIssues = []issue{existing}
+
+ _, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+
+ search := m.lastBody(t, "/search/jql")
+ assert.Contains(t, search["fields"], "labels")
+
+ m.mu.Lock()
+ var putLabels []any
+ for _, r := range m.reqs {
+ if r.method == http.MethodPut {
+ putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any)
+ }
+ }
+ m.mu.Unlock()
+ assert.Contains(t, putLabels, "user-added-label")
+ assert.Contains(t, putLabels, "signoz-alert")
+ assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates
+ // the dedup label is re-asserted
+ found := false
+ for _, l := range putLabels {
+ if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") {
+ found = true
+ }
+ }
+ assert.True(t, found)
+}
+
+func TestADFDocLen(t *testing.T) {
+ text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} }
+ para := func(children ...any) map[string]any {
+ return map[string]any{"type": "paragraph", "content": children}
+ }
+ cases := []struct {
+ name string
+ node any
+ want int
+ }{
+ {"text node", text("hello"), 7}, // 5 utf16 + 2 overhead
+ {"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead
+ {"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2)
+ {"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2
+ {"non-map is zero", "junk", 0},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ assert.Equal(t, c.want, adfDocLen(c.node))
+ })
+ }
+}
+
+// 30 fat custom bodies overflow Jira's description accounting (text + per-node
+// overhead); the built doc must be shrunk under the limit, never rejected.
+func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) {
+ m := newMockJira(t)
+ filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25)
+ alerts := make([]*types.Alert, 0, 30)
+ for i := range 30 {
+ a := alert(true)
+ a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26)))
+ a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe"
+ a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler)
+ alerts = append(alerts, a)
+ }
+
+ _, err := newNotifier(t, m).Notify(ctx(), alerts...)
+ require.NoError(t, err)
+
+ body := m.lastBody(t, "/issue")
+ fields, ok := body["fields"].(map[string]any)
+ require.True(t, ok)
+ desc := fields["description"]
+ assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes)
+
+ js, err := json.Marshal(desc)
+ require.NoError(t, err)
+ assert.Contains(t, string(js), "FIRING") // status panel survives the shrink
+ assert.Contains(t, string(js), "…") // body ends with the shrink marker
+}
+
+func TestFiringSearchJQLHasReopenWindow(t *testing.T) {
+ m := newMockJira(t)
+ _, err := newNotifier(t, m).Notify(ctx(), alert(true))
+ require.NoError(t, err)
+
+ body := m.lastBody(t, "/search/jql")
+ jql, ok := body["jql"].(string)
+ require.True(t, ok)
+ // newNotifier uses a 3d window → 4320 minutes.
+ assert.Contains(t, jql, "resolutiondate >= -4320m")
+}
+
+func TestSelectTransition(t *testing.T) {
+ ts := []jiraTransition{
+ transition("41", "Done", "done"),
+ transition("51", "Won't Do", "done"),
+ transition("11", "To Do", "new"),
+ }
+ assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category
+ assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override
+ assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done
+ assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback
+ assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip
+}
+
+func TestResolveCloudID(t *testing.T) {
+ cases := []struct {
+ name string
+ handler http.HandlerFunc
+ want string
+ wantErr bool
+ wantRetry bool
+ }{
+ {
+ name: "success",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/_edge/tenant_info", r.URL.Path)
+ _, _ = w.Write([]byte(`{"cloudId":"abc-123"}`))
+ },
+ want: "abc-123",
+ },
+ {
+ name: "non-200 is not retryable",
+ handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
+ wantErr: true,
+ },
+ {
+ name: "empty cloud id",
+ handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) },
+ wantErr: true,
+ },
+ {
+ name: "bad json",
+ handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) },
+ wantErr: true,
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ srv := httptest.NewServer(c.handler)
+ defer srv.Close()
+
+ n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil)
+ require.NoError(t, err)
+
+ got, retry, err := n.resolveCloudID(context.Background())
+ if c.wantErr {
+ assert.Error(t, err)
+ assert.Equal(t, c.wantRetry, retry)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, c.want, got)
+ })
+ }
+}
diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
new file mode 100644
index 00000000000..2b39fa8edf4
--- /dev/null
+++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 SigNoz, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package jsmops delivers Jira Service Management Ops alerts by reusing the
+// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM
+// config onto config.OpsGenieConfig with APIURL pinned to the JSM native
+// integration-events gateway.
+package jsmops
+
+import (
+ "log/slog"
+ "net/url"
+
+ "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
+ "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
+ "github.com/prometheus/alertmanager/config"
+ "github.com/prometheus/alertmanager/template"
+ commoncfg "github.com/prometheus/common/config"
+)
+
+const (
+ Integration = "jsmops"
+ source = "SigNoz"
+)
+
+// New builds an Opsgenie notifier pointed at the JSM native endpoint.
+// advancedFeatures enables the rich treatment: HTML body and a note timeline
+// (per fire and on resolve).
+func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) {
+ conf, err := toOpsGenieConfig(c)
+ if err != nil {
+ return nil, err
+ }
+ return opsgenie.New(conf, t, l, templater, advancedFeatures)
+}
+
+// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL
+// pinned to the JSM native gateway.
+func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) {
+ apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ httpConfig := c.HTTPConfig
+ if httpConfig == nil {
+ httpConfig = &commoncfg.HTTPClientConfig{}
+ }
+
+ return &config.OpsGenieConfig{
+ NotifierConfig: c.NotifierConfig,
+ HTTPConfig: httpConfig,
+ APIKey: c.APIKey,
+ APIURL: &config.URL{URL: apiURL},
+ Message: c.Message,
+ Description: c.Description,
+ Priority: c.Priority,
+ Tags: c.Tags,
+ Source: source,
+ }, nil
+}
diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
new file mode 100644
index 00000000000..757240ff193
--- /dev/null
+++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
@@ -0,0 +1,41 @@
+package jsmops
+
+import (
+ "testing"
+
+ "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
+ commoncfg "github.com/prometheus/common/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestToOpsGenieConfig(t *testing.T) {
+ c := &alertmanagertypes.JSMOpsReceiverConfig{
+ APIKey: "key-123",
+ Message: "msg",
+ Description: "desc",
+ Priority: "P1",
+ Tags: "signoz",
+ HTTPConfig: &commoncfg.HTTPClientConfig{},
+ }
+
+ og, err := toOpsGenieConfig(c)
+ require.NoError(t, err)
+
+ // Trailing slash is required: the Opsgenie notifier appends "v2/alerts..."
+ // with no separator, yielding /jsm/ops/integration/v2/alerts.
+ assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String())
+ assert.Equal(t, "key-123", string(og.APIKey))
+ assert.Equal(t, "msg", og.Message)
+ assert.Equal(t, "desc", og.Description)
+ assert.Equal(t, "P1", og.Priority)
+ assert.Equal(t, "signoz", og.Tags)
+ assert.Equal(t, source, og.Source)
+ assert.Same(t, c.HTTPConfig, og.HTTPConfig)
+}
+
+func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) {
+ og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"})
+ require.NoError(t, err)
+ assert.NotNil(t, og.HTTPConfig)
+}
diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go
index 96bfc34675f..7a2703896a5 100644
--- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go
+++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go
@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"strings"
+ "unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
@@ -32,8 +33,13 @@ const (
Integration = "opsgenie"
)
-// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
-const maxMessageLenRunes = 130
+// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130,
+// description 15000, note 25000 runes.
+const (
+ maxMessageLenRunes = 130
+ maxDescriptionLenRunes = 15000
+ maxNoteLenRunes = 25000
+)
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
@@ -43,21 +49,29 @@ type Notifier struct {
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
+ // advancedFeatures bundles the JSM Ops enrichments: render the default body as
+ // HTML (markdown -> HTML), and post a note per fire and on resolve to build an
+ // immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part
+ // rides on the upstream UpdateAlerts config flag, set alongside this.
+ advancedFeatures bool
}
-// New returns a new OpsGenie notifier.
-func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
+// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops
+// enrichments (HTML default body + a note timeline per fire and on resolve);
+// pass false for plain OpsGenie.
+func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
- conf: c,
- tmpl: t,
- logger: l,
- client: client,
- retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
- templater: templater,
+ conf: c,
+ tmpl: t,
+ logger: l,
+ client: client,
+ retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
+ templater: templater,
+ advancedFeatures: advancedFeatures,
}, nil
}
@@ -94,6 +108,30 @@ type opsGenieUpdateDescriptionMessage struct {
Description string `json:"description,omitempty"`
}
+type opsGenieAddNoteMessage struct {
+ Note string `json:"note"`
+ Source string `json:"source"`
+}
+
+// noteRequest builds a POST to the alert's notes endpoint (append-only timeline).
+func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) {
+ noteEndpointURL := n.conf.APIURL.Copy()
+ noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias)
+ q := noteEndpointURL.Query()
+ q.Set("identifierType", "alias")
+ noteEndpointURL.RawQuery = q.Encode()
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf)
+ if err != nil {
+ return nil, err
+ }
+ return req.WithContext(ctx), nil
+}
+
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
requests, retry, err := n.createRequests(ctx, as...)
@@ -110,12 +148,24 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
notify.Drain(resp)
if err != nil {
+ // notes are enrichment; a permanently-failed note (e.g. the first-fire
+ // note racing JSM's async alert create) must not fail the notification
+ if !shouldRetry && isNoteRequest(req) {
+ n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err))
+ continue
+ }
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
}
return true, nil
}
+// isNoteRequest reports whether req targets the notes endpoint, the only one
+// built by noteRequest.
+func isNoteRequest(req *http.Request) bool {
+ return strings.HasSuffix(req.URL.Path, "/notes")
+}
+
// Like Split but filter out empty strings.
func safeSplit(s, sep string) []string {
a := strings.Split(strings.TrimSpace(s), sep)
@@ -145,28 +195,13 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
}
var description string
- if result.IsDefaultBody {
+ if result.IsDefaultBody && !n.advancedFeatures {
description = strings.Join(result.Body, "\n")
} else {
- var b strings.Builder
- first := true
- for _, part := range result.Body {
- if part == "" {
- continue
- }
- rendered, renderErr := markdownrenderer.RenderHTML(part)
- if renderErr != nil {
- return "", "", renderErr
- }
- if !first {
- b.WriteString("-wrapped,
+//
-joined) while keeping the total within budget runes. An over-budget part
+// is shrunk at the markdown level and re-rendered so the HTML stays well-formed;
+// fully dropped parts are summarized by a "+N more" trailer.
+func buildHTMLDescription(parts []string, budget int) (string, error) {
+ rendering := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if part != "" {
+ rendering = append(rendering, part)
+ }
+ }
+
+ budget -= descriptionTrailerReserveRunes
+ var b strings.Builder
+ used, included := 0, 0
+ for _, part := range rendering {
+ rendered, err := markdownrenderer.RenderHTML(part)
+ if err != nil {
+ return "", err
+ }
+ overhead := len("
")
+ if included > 0 {
+ overhead += len("
")
+ }
+ if used+overhead+utf8.RuneCountInString(rendered) > budget {
+ rendered, err = shrinkMarkdownToFit(part, budget-used-overhead)
+ if err != nil {
+ return "", err
+ }
+ if rendered == "" {
+ break
+ }
+ }
+ if included > 0 {
+ b.WriteString("
")
+ }
+ b.WriteString("
")
+ b.WriteString(rendered)
+ b.WriteString("
")
+ used += overhead + utf8.RuneCountInString(rendered)
+ included++
+ }
+ if dropped := len(rendering) - included; dropped > 0 {
+ fmt.Fprintf(&b, "
…and %d more alerts. Open in SigNoz for the full list.
", dropped)
+ }
+ return b.String(), nil
+}
+
+// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget
+// runes, returning "" when the budget is too small to carry anything useful.
+// Only the markdown is ever cut, never the rendered HTML, so goldmark always
+// emits balanced markup.
+func shrinkMarkdownToFit(md string, budget int) (string, error) {
+ if budget < minShrinkBudgetRunes {
+ return "", nil
+ }
+ for range 4 {
+ rendered, err := markdownrenderer.RenderHTML(md)
+ if err != nil {
+ return "", err
+ }
+ renderedLen := utf8.RuneCountInString(rendered)
+ if renderedLen <= budget {
+ return rendered, nil
+ }
+ runes := []rune(md)
+ keep := len(runes) * budget / renderedLen * 9 / 10
+ if keep >= len(runes) {
+ keep = len(runes) - 1
+ }
+ if keep < minShrinkBudgetRunes {
+ return "", nil
+ }
+ md = string(runes[:keep]) + "…"
+ }
+ return "", nil
+}
+
+// prepareNote renders the same body template as plain text for a timeline note.
+// JSM Ops notes render neither HTML nor markdown, so links flatten to
+// "text (url)" and all markers are stripped.
+func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) {
+ customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
+ result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
+ TitleTemplate: customTitle,
+ BodyTemplate: customBody,
+ DefaultTitleTemplate: n.conf.Message,
+ DefaultBodyTemplate: n.conf.Description,
+ }, alerts)
+ if err != nil {
+ return "", err
+ }
+
+ var b strings.Builder
+ first := true
+ for _, part := range result.Body {
+ text, renderErr := markdownrenderer.RenderPlainText(part)
+ if renderErr != nil {
+ return "", renderErr
+ }
+ if text = strings.TrimSpace(text); text == "" {
+ continue
+ }
+ if !first {
+ b.WriteString("\n\n")
+ }
+ b.WriteString(text)
+ first = false
+ }
+
+ note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes)
+ if truncated {
+ n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes))
+ }
+ return note, nil
+}
+
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -206,6 +373,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
)
switch alerts.Status() {
case model.AlertResolved:
+ // Post the resolved snapshot to the timeline before closing (closed alerts
+ // reject notes), so the note lands first.
+ if n.advancedFeatures {
+ note, err := n.prepareNote(ctx, as)
+ if err != nil {
+ n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
+ return nil, false, err
+ }
+ noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
+ if err != nil {
+ return nil, true, err
+ }
+ requests = append(requests, noteReq)
+ }
+
resolvedEndpointURL := n.conf.APIURL.Copy()
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
q := resolvedEndpointURL.Query()
@@ -322,6 +504,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
}
+
+ // Append this fire's snapshot to the timeline (every fire, including the
+ // first, so no datapoint is lost when the description is overwritten).
+ // Notes are plain text, so this uses the plain-text render, not the HTML body.
+ if n.advancedFeatures {
+ note, err := n.prepareNote(ctx, as)
+ if err != nil {
+ return nil, false, err
+ }
+ noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
+ if err != nil {
+ return nil, true, err
+ }
+ requests = append(requests, noteReq)
+ }
}
var apiKey string
diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go
index 6b935fc4455..87c6ed9013d 100644
--- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go
+++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go
@@ -6,14 +6,18 @@ package opsgenie
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
+ "net/http/httptest"
"net/url"
"os"
+ "strings"
"testing"
"time"
+ "unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -44,6 +48,7 @@ func TestOpsGenieRetry(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
+ false,
)
require.NoError(t, err)
@@ -69,6 +74,7 @@ func TestOpsGenieRedactedURL(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
+ false,
)
require.NoError(t, err)
@@ -96,6 +102,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
+ false,
)
require.NoError(t, err)
@@ -216,7 +223,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
- notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
+ notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false)
require.NoError(t, err)
ctx := context.Background()
@@ -292,7 +299,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
- notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
+ notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -324,6 +331,111 @@ func TestOpsGenieWithUpdate(t *testing.T) {
assert.JSONEq(t, `{"description":"new description"}`, body2)
}
+func TestOpsGenieAdvancedFeatures(t *testing.T) {
+ u, err := url.Parse("https://test-opsgenie-url")
+ require.NoError(t, err)
+ tmpl := test.CreateTmpl(t)
+ ctx := notify.WithGroupKey(context.Background(), "1")
+ key, _ := notify.ExtractGroupKey(ctx)
+ alias := key.Hash()
+
+ cfg := &config.OpsGenieConfig{
+ NotifierConfig: config.NotifierConfig{VSendResolved: true},
+ Message: `{{ .CommonLabels.Message }}`,
+ Description: `{{ .CommonLabels.Description }}`,
+ UpdateAlerts: true,
+ APIKey: "k",
+ APIURL: &config.URL{URL: u},
+ HTTPConfig: &commoncfg.HTTPClientConfig{},
+ }
+ notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
+ require.NoError(t, err)
+
+ firing := &types.Alert{Alert: model.Alert{
+ StartsAt: time.Now(),
+ EndsAt: time.Now().Add(time.Hour),
+ Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"},
+ }}
+
+ // Fire: create + update message + update description + a timeline note.
+ reqs, _, err := notifier.createRequests(ctx, firing)
+ require.NoError(t, err)
+ require.Len(t, reqs, 4)
+ assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String())
+ assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String())
+ assert.Equal(t, http.MethodPost, reqs[3].Method)
+
+ // the note body is the plain-text render: markers stripped, link flattened
+ var noteMsg opsGenieAddNoteMessage
+ require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), ¬eMsg))
+ assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note)
+
+ // Resolve: note posted before the close.
+ resolved := &types.Alert{Alert: model.Alert{
+ StartsAt: time.Now().Add(-time.Hour),
+ EndsAt: time.Now().Add(-time.Minute),
+ Labels: model.LabelSet{"Message": "m", "Description": "d"},
+ }}
+ reqs, _, err = notifier.createRequests(ctx, resolved)
+ require.NoError(t, err)
+ require.Len(t, reqs, 2)
+ assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String())
+ assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String())
+}
+
+func TestOpsGenieNotifyBestEffortNote(t *testing.T) {
+ tmpl := test.CreateTmpl(t)
+ ctx := notify.WithGroupKey(context.Background(), "1")
+
+ firing := &types.Alert{Alert: model.Alert{
+ StartsAt: time.Now(),
+ EndsAt: time.Now().Add(time.Hour),
+ Labels: model.LabelSet{"Message": "m", "Description": "d"},
+ }}
+
+ for _, tc := range []struct {
+ name string
+ createStatus int
+ noteStatus int
+ wantErr bool
+ wantRetry bool
+ }{
+ {name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true},
+ {name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true},
+ {name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/notes") {
+ w.WriteHeader(tc.noteStatus)
+ return
+ }
+ w.WriteHeader(tc.createStatus)
+ }))
+ defer srv.Close()
+
+ u, err := url.Parse(srv.URL)
+ require.NoError(t, err)
+ notifier, err := New(&config.OpsGenieConfig{
+ Message: `{{ .CommonLabels.Message }}`,
+ Description: `{{ .CommonLabels.Description }}`,
+ APIKey: "k",
+ APIURL: &config.URL{URL: u},
+ HTTPConfig: &commoncfg.HTTPClientConfig{},
+ }, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
+ require.NoError(t, err)
+
+ retry, err := notifier.Notify(ctx, firing)
+ if tc.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ assert.Equal(t, tc.wantRetry, retry)
+ })
+ }
+}
+
func TestOpsGenieApiKeyFile(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
@@ -335,7 +447,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
- notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
+ notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -437,6 +549,109 @@ func TestPrepareContent(t *testing.T) {
})
}
+func TestShrinkMarkdownToFit(t *testing.T) {
+ cases := []struct {
+ name string
+ md string
+ budget int
+ wantEmpty bool
+ }{
+ {"fits untouched", "**bold** text", 1000, false},
+ {"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false},
+ {"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := shrinkMarkdownToFit(c.md, c.budget)
+ require.NoError(t, err)
+ if c.wantEmpty {
+ assert.Empty(t, got)
+ return
+ }
+ assert.NotEmpty(t, got)
+ assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
+ assert.Equal(t, strings.Count(got, "
"), strings.Count(got, "
"))
+ })
+ }
+}
+
+func TestBuildHTMLDescriptionOverflow(t *testing.T) {
+ bigPart := strings.Repeat("alpha beta gamma ", 100)
+ cases := []struct {
+ name string
+ parts []string
+ budget int
+ wantTrailer bool
+ }{
+ {"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false},
+ {"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false},
+ {"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true},
+ {"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := buildHTMLDescription(c.parts, c.budget)
+ require.NoError(t, err)
+ assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
+ assert.Equal(t, strings.Count(got, "
"), strings.Count(got, "
"))
+ assert.True(t, strings.HasSuffix(got, "
"))
+ if c.wantTrailer {
+ assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got)
+ } else {
+ assert.NotContains(t, got, "more alerts")
+ }
+ })
+ }
+}
+
+// prepareContent end-to-end: 40 custom-template alerts overflow the description
+// budget yet the posted HTML stays within limits and well-formed.
+func TestPrepareContentDescriptionOverflow(t *testing.T) {
+ tmpl := test.CreateTmpl(t)
+ notifier := &Notifier{
+ conf: &config.OpsGenieConfig{
+ Message: `{{ .CommonLabels.alertname }}`,
+ Description: `{{ .CommonLabels.alertname }}`,
+ },
+ tmpl: tmpl,
+ logger: promslog.NewNopLogger(),
+ templater: newTestTemplater(tmpl),
+ advancedFeatures: true,
+ }
+
+ bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30)
+ alerts := make([]*types.Alert, 0, 40)
+ for i := range 40 {
+ alerts = append(alerts, &types.Alert{
+ Alert: model.Alert{
+ Labels: model.LabelSet{
+ "alertname": "overflow",
+ "namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)),
+ },
+ Annotations: model.LabelSet{
+ ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
+ },
+ StartsAt: time.Now(),
+ EndsAt: time.Now().Add(time.Hour),
+ },
+ })
+ }
+
+ _, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts)
+ require.NoError(t, err)
+ assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes)
+ assert.Equal(t, strings.Count(desc, "