mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-01 17:10:42 +01:00
Compare commits
4 Commits
feat/ai-qu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2d094b1f | ||
|
|
7c50fe3763 | ||
|
|
160a1b018c | ||
|
|
27acdbd970 |
@@ -109,6 +109,57 @@ components:
|
||||
webhook_url:
|
||||
$ref: '#/components/schemas/ConfigSecretURL'
|
||||
type: object
|
||||
AlertmanagertypesJSMOpsReceiverConfig:
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
issue_type:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopen_duration:
|
||||
$ref: '#/components/schemas/ModelDuration'
|
||||
reopen_transition:
|
||||
type: string
|
||||
resolve_transition:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -162,6 +213,10 @@ components:
|
||||
oneOf:
|
||||
- required:
|
||||
- googlechat_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- jsmops_configs
|
||||
- required:
|
||||
- discord_configs
|
||||
- required:
|
||||
@@ -192,8 +247,6 @@ components:
|
||||
- msteams_configs
|
||||
- required:
|
||||
- msteamsv2_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- rocketchat_configs
|
||||
- required:
|
||||
@@ -217,7 +270,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -344,7 +401,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
|
||||
@@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
|
||||
webhook_url?: ConfigSecretURLDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_key?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO {
|
||||
url_file?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface ConfigMattermostFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -3268,6 +3300,67 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum InfraMonitoringEvents {
|
||||
MetricsView = 'metrics',
|
||||
Total = 'total',
|
||||
Cluster = 'cluster',
|
||||
Container = 'container',
|
||||
DaemonSet = 'daemonSet',
|
||||
Deployment = 'deployment',
|
||||
Job = 'job',
|
||||
|
||||
@@ -4,8 +4,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import styles from './ColumnHeader.module.scss';
|
||||
import cx from 'classnames';
|
||||
import { MouseEventHandler } from 'react';
|
||||
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 ColumnHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -43,7 +44,7 @@ function ColumnHeader({
|
||||
<div onClick={stopPropagationHandler}>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={stopPropagationHandler}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Group, Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './EntityGroupHeader.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 EntityGroupHeaderProps {
|
||||
title: string;
|
||||
@@ -28,7 +29,7 @@ function EntityGroupHeader({
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Copy, X } from '@signozhq/icons';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
@@ -95,12 +94,14 @@ export default function K8sBaseDetails<T>({
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
),
|
||||
[
|
||||
queryKeyPrefix,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
selectedTime,
|
||||
getAutoRefreshQueryKey,
|
||||
],
|
||||
@@ -170,14 +171,9 @@ export default function K8sBaseDetails<T>({
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
if (selectedItem) {
|
||||
copyToClipboard(selectedItem);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}
|
||||
}, [copyToClipboard, selectedItem]);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
@@ -211,17 +207,13 @@ export default function K8sBaseDetails<T>({
|
||||
(isEntityLoading && 'Loading...') ||
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<TooltipSimple title="Copy ID">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
data-testid="copy-id-button"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
) as unknown as string;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
} from '../hooks';
|
||||
|
||||
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem';
|
||||
import { K8sBaseDetailsContentProps } from './types';
|
||||
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
|
||||
|
||||
@@ -239,41 +239,18 @@ export default function K8sBaseDetailsContent<T>({
|
||||
<>
|
||||
<div className={styles.entityDetailsEntity}>
|
||||
<div className={styles.entityDetailsGrid}>
|
||||
<div className={styles.labelsRow}>
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
return (
|
||||
<EntityMetadataItem
|
||||
key={config.label}
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.valuesRow}>
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
if (config.render) {
|
||||
return config.render(value, entity);
|
||||
}
|
||||
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataValue}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
label={config.label}
|
||||
value={String(value)}
|
||||
renderedValue={config.render?.(value, entity)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -214,6 +214,7 @@ export function K8sExpandedRow<
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
);
|
||||
queryClient.setQueryData(detailQueryKey, { data: row });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.metadataItem}>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.label}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
|
||||
{renderedValue ?? (
|
||||
<div className={styles.valueRow}>
|
||||
<TooltipSimple title={value} arrow side="bottom" align="start">
|
||||
<span className={styles.value}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.valueText}
|
||||
>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{!!value && (
|
||||
<CopyButton
|
||||
value={value}
|
||||
size={10}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
className={styles.copyButton}
|
||||
testId={`copy-metadata-${label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<EntityMetadataItem label="Cluster Name" value="prod-cluster" />);
|
||||
|
||||
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(
|
||||
<EntityMetadataItem
|
||||
label="Image:Tag"
|
||||
value="ghcr.io/open-telemetry/demo:1.12.0-loadgenerator"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<EntityMetadataItem label="Node" value="" />);
|
||||
|
||||
expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the full value on hover', async () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Node"
|
||||
value="gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
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(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
// 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(
|
||||
<EntityMetadataItem
|
||||
label="Status"
|
||||
value="running"
|
||||
renderedValue={<span data-testid="custom">custom node</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<string, AnyEntityConfig> = {
|
||||
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
|
||||
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
|
||||
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
|
||||
[K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
|
||||
};
|
||||
|
||||
export function getEntityConfig(category: string): AnyEntityConfig | undefined {
|
||||
|
||||
@@ -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> = {},
|
||||
): 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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<InframonitoringtypesContainerRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
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.",
|
||||
},
|
||||
];
|
||||
@@ -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<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEntityData(
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<
|
||||
K8sEntityConfig<InframonitoringtypesContainerRecordDTO>['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<RenderErrorResponseDTO>) ?? 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,
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
@@ -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<InframonitoringtypesContainerRecordDTO>;
|
||||
|
||||
/**
|
||||
* 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 => <EntityGroupHeader title="Container Group" />,
|
||||
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 => (
|
||||
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
|
||||
<K8sGroupCell row={row} />
|
||||
</ExpandButtonWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Container Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
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 => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'podName',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#pod-name`}>
|
||||
Pod Name
|
||||
</ColumnHeader>
|
||||
),
|
||||
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 <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{podName}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'namespace',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Namespace
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
width: { min: 160 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#imagetag`}>
|
||||
Image:Tag
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => getContainerImageWithTag(row),
|
||||
width: { min: 240 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const image = value as string;
|
||||
if (!image) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{image}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
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 <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
|
||||
{CONTAINER_STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
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 <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerStatusItems(row.containerCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
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 <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_READY_COLORS[row.ready]} variant="outline">
|
||||
{CONTAINER_READY_LABELS[row.ready]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
|
||||
row.containerCountsByReady,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.containerCountsByReady) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerReadyItems(row.containerCountsByReady)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerRestarts',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#restarts`}>
|
||||
Restarts
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.restarts,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Restarts"
|
||||
>
|
||||
<TanStackTable.Text>{value as number}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#cpu-usage-cores`}>
|
||||
CPU Usage (cores)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpu,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={Number(value)}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU metric"
|
||||
>
|
||||
<TanStackTable.Text>{Number(value).toFixed(2)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#mem-usage-wss`}>
|
||||
Memory Usage (WSS)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memory,
|
||||
width: { min: 210, default: '100%' },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="memory metric"
|
||||
>
|
||||
<TanStackTable.Text>{formatBytes(value as number)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'node',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Node
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cluster',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Cluster
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'deployment',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Deployment
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
169
frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
Normal file
169
frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
Normal file
@@ -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<keyof InframonitoringtypesContainerCountsByStatusDTO, string>
|
||||
> = {
|
||||
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 },
|
||||
];
|
||||
}
|
||||
@@ -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}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
.title {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
|
||||
--typography-margin: 0px var(--spacing-4) 0px 0px;
|
||||
--typography-margin: 0px var(--spacing-1) 0px 0px;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
.entityDetailsEntity {
|
||||
@@ -45,30 +53,12 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 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: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.labelsRow,
|
||||
.valuesRow {
|
||||
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 {
|
||||
|
||||
@@ -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: <Box size={14} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
|
||||
@@ -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, string> =
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
|
||||
[InfraMonitoringEntity.JOBS]: 'k8s.',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = {
|
||||
<Inbox size={16} /> Kubernetes
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`,
|
||||
key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
};
|
||||
|
||||
557
pkg/alertmanager/alertmanagernotify/jira/jira.go
Normal file
557
pkg/alertmanager/alertmanagernotify/jira/jira.go
Normal file
@@ -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])
|
||||
}
|
||||
489
pkg/alertmanager/alertmanagernotify/jira/jira_test.go
Normal file
489
pkg/alertmanager/alertmanagernotify/jira/jira_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
61
pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
Normal file
61
pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
Normal file
@@ -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
|
||||
}
|
||||
41
pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
Normal file
41
pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
first = false
|
||||
description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
description = b.String()
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
|
||||
@@ -174,9 +209,141 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
|
||||
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
// The API silently truncates over-limit descriptions, which would drop the
|
||||
// trailing SigNoz link; cap here with an ellipsis instead. The HTML path is
|
||||
// pre-fitted above, so this only ever cuts the plain-text default body.
|
||||
description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes)
|
||||
if descTruncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes))
|
||||
}
|
||||
|
||||
return title, description, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// room reserved for the "+N more" trailer appended when parts are dropped.
|
||||
descriptionTrailerReserveRunes = 80
|
||||
// below this rendering budget a shrunk part carries no signal; drop it instead.
|
||||
minShrinkBudgetRunes = 64
|
||||
)
|
||||
|
||||
// buildHTMLDescription renders each markdown part to HTML (<div>-wrapped,
|
||||
// <hr>-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("<div></div>")
|
||||
if included > 0 {
|
||||
overhead += len("<hr>")
|
||||
}
|
||||
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("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
used += overhead + utf8.RuneCountInString(rendered)
|
||||
included++
|
||||
}
|
||||
if dropped := len(rendering) - included; dropped > 0 {
|
||||
fmt.Fprintf(&b, "<hr><div><i>…and %d more alerts. Open in SigNoz for the full list.</i></div>", 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
|
||||
|
||||
@@ -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, "<p>"), strings.Count(got, "</p>"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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, "<div>"), strings.Count(got, "</div>"))
|
||||
assert.True(t, strings.HasSuffix(got, "</div>"))
|
||||
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, "<div>"), strings.Count(desc, "</div>"))
|
||||
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc)
|
||||
}
|
||||
|
||||
func repeatParts(part string, n int) []string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = part
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
@@ -26,6 +28,8 @@ var customNotifierIntegrations = []string{
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
googlechat.Integration,
|
||||
jira.Integration,
|
||||
jsmops.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -66,7 +70,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.OpsGenieConfigs {
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) })
|
||||
}
|
||||
for i, c := range nc.SlackConfigs {
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
|
||||
@@ -81,6 +85,16 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return googlechat.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JiraConfigs {
|
||||
add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jira.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JSMOpsConfigs {
|
||||
add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jsmops.New(c, tmpl, l, templater, true)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
@@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) {
|
||||
}
|
||||
|
||||
// CreateRule stores rule def into db and also
|
||||
// starts an executor for the rule
|
||||
// starts an executor for the rule, unless the rule is disabled
|
||||
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
@@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
}
|
||||
|
||||
taskName := prepareTaskName(id.StringValue())
|
||||
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
|
||||
if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -39,6 +40,52 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error {
|
||||
}
|
||||
|
||||
func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
// Frozen copy of the defaults as this migration shipped (hence the old
|
||||
// camelCase keys); migrations must not read live types. 031 replaces these rows.
|
||||
defaultFilters := []struct {
|
||||
signal string
|
||||
filters []map[string]any
|
||||
}{
|
||||
{"traces", []map[string]any{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
|
||||
{"key": "responseStatusCode", "dataType": "string", "type": "resource"},
|
||||
{"key": "httpHost", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpMethod", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpRoute", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpUrl", "dataType": "string", "type": "tag"},
|
||||
{"key": "traceID", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"logs", []map[string]any{
|
||||
{"key": "severity_text", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
{"api_monitoring", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"exceptions", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -72,15 +119,31 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the default quick filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
now := time.Now()
|
||||
quickFilters := make([]*quickFilter, 0, len(defaultFilters))
|
||||
for _, defaultFilter := range defaultFilters {
|
||||
filterJSON, err := json.Marshal(defaultFilter.filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quickFilters = append(quickFilters, &quickFilter{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: defaultOrg.StringValue(),
|
||||
Filter: string(filterJSON),
|
||||
Signal: defaultFilter.signal,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert all filters at once
|
||||
_, err = tx.NewInsert().
|
||||
Model(&storableQuickFilters).
|
||||
Model(&quickFilters).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,11 +3,13 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -38,6 +40,61 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er
|
||||
}
|
||||
|
||||
func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
// Frozen copy of the defaults as this migration shipped; migrations must not
|
||||
// read live types. api_monitoring's service.name is "tag" here — 035 fixes it.
|
||||
defaultFilters := []struct {
|
||||
signal string
|
||||
filters []map[string]any
|
||||
}{
|
||||
{"traces", []map[string]any{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "name", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "response_status_code", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_host", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.route", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_url", "dataType": "string", "type": "tag"},
|
||||
{"key": "trace_id", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"logs", []map[string]any{
|
||||
{"key": "severity_text", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
{"api_monitoring", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"exceptions", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
}
|
||||
|
||||
signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters))
|
||||
for _, defaultFilter := range defaultFilters {
|
||||
filterJSON, err := json.Marshal(defaultFilter.filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)})
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -73,17 +130,28 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// For each organization, create new quick filters with the updated NewDefaultQuickFilter function
|
||||
// For each organization, create new quick filters with the updated defaults
|
||||
for _, orgID := range orgIDs {
|
||||
// Get the updated default quick filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
now := time.Now()
|
||||
quickFilters := make([]*quickFilter, 0, len(signalFilters))
|
||||
for _, signalFilter := range signalFilters {
|
||||
quickFilters = append(quickFilters, &quickFilter{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: signalFilter.filter,
|
||||
Signal: signalFilter.signal,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert all filters for this organization
|
||||
_, err = tx.NewInsert().
|
||||
Model(&storableQuickFilters).
|
||||
Model(&quickFilters).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -2,20 +2,16 @@ package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type updateApiMonitoringFilters struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
type updateApiMonitoringFilters struct{}
|
||||
|
||||
func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
@@ -23,10 +19,8 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updateApiMonitoringFilters{
|
||||
store: store,
|
||||
}, nil
|
||||
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updateApiMonitoringFilters{}, nil
|
||||
}
|
||||
|
||||
func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error {
|
||||
@@ -38,63 +32,29 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat
|
||||
}
|
||||
|
||||
func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
// Frozen copy of the api_monitoring defaults as this migration shipped; the
|
||||
// change over 031 is service.name moving from "tag" to "resource".
|
||||
apiMonitoringFilters := []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
}
|
||||
|
||||
apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
// Get all organization IDs as strings
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
// The filter JSON is org-independent, so one update covers every org's row.
|
||||
_, err = db.NewUpdate().
|
||||
Table("quick_filter").
|
||||
Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()).
|
||||
Where("signal = ?", "api_monitoring").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
// Get the updated default quick filters which includes the new API monitoring filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the API monitoring filter from the storable quick filters
|
||||
var apiMonitoringFilterJSON string
|
||||
for _, filter := range storableQuickFilters {
|
||||
if filter.Signal == quickfiltertypes.SignalApiMonitoring {
|
||||
apiMonitoringFilterJSON = filter.Filter
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if apiMonitoringFilterJSON != "" {
|
||||
_, err = tx.NewUpdate().
|
||||
Table("quick_filter").
|
||||
Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()).
|
||||
Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
173
pkg/templating/markdownrenderer/adf/adf.go
Normal file
173
pkg/templating/markdownrenderer/adf/adf.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package adf converts Markdown into Atlassian Document Format (ADF) nodes,
|
||||
// the JSON rich-text format used by Jira Cloud's v3 API.
|
||||
package adf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
// parser is stateless across Parse calls and safe for concurrent use; only
|
||||
// goldmark's renderers hold per-document state (which we don't use). Strikethrough
|
||||
// is included for the strike mark; linkify is deliberately omitted since it
|
||||
// fragments plain text into word tokens while scanning for bare URLs.
|
||||
var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser()
|
||||
|
||||
// Render returns the ADF block nodes for markdown (without the doc wrapper),
|
||||
// so callers can embed them alongside their own nodes (panels, links, …).
|
||||
func Render(markdown string) []any {
|
||||
src := []byte(markdown)
|
||||
return blockChildren(parser.Parse(text.NewReader(src)), src)
|
||||
}
|
||||
|
||||
func blockChildren(parent ast.Node, src []byte) []any {
|
||||
var out []any
|
||||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if b := block(c, src); b != nil {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func block(n ast.Node, src []byte) any {
|
||||
switch node := n.(type) {
|
||||
case *ast.Heading:
|
||||
return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)}
|
||||
case *ast.Paragraph:
|
||||
return paragraph(inlineChildren(node, src, nil))
|
||||
case *ast.TextBlock:
|
||||
return paragraph(inlineChildren(node, src, nil))
|
||||
case *ast.List:
|
||||
typ := "bulletList"
|
||||
if node.IsOrdered() {
|
||||
typ = "orderedList"
|
||||
}
|
||||
return map[string]any{"type": typ, "content": blockChildren(node, src)}
|
||||
case *ast.ListItem:
|
||||
return map[string]any{"type": "listItem", "content": blockChildren(node, src)}
|
||||
case *ast.Blockquote:
|
||||
return map[string]any{"type": "blockquote", "content": blockChildren(node, src)}
|
||||
case *ast.FencedCodeBlock:
|
||||
return codeBlock(codeText(node, src), string(node.Language(src)))
|
||||
case *ast.CodeBlock:
|
||||
return codeBlock(codeText(node, src), "")
|
||||
case *ast.ThematicBreak:
|
||||
return map[string]any{"type": "rule"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func paragraph(content []any) map[string]any {
|
||||
p := map[string]any{"type": "paragraph"}
|
||||
if len(content) > 0 {
|
||||
p["content"] = content
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func codeBlock(code, lang string) map[string]any {
|
||||
cb := map[string]any{"type": "codeBlock"}
|
||||
if lang != "" {
|
||||
cb["attrs"] = map[string]any{"language": lang}
|
||||
}
|
||||
if code = strings.TrimRight(code, "\n"); code != "" {
|
||||
cb["content"] = []any{map[string]any{"type": "text", "text": code}}
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
// inlineChildren flattens an inline subtree into ADF text nodes, carrying the
|
||||
// active marks (strong/em/code/strike/link) down the tree.
|
||||
func inlineChildren(parent ast.Node, src []byte, marks []any) []any {
|
||||
var out []any
|
||||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch node := c.(type) {
|
||||
case *ast.Text:
|
||||
if t := string(node.Segment.Value(src)); t != "" {
|
||||
out = append(out, textNode(t, marks))
|
||||
}
|
||||
if node.HardLineBreak() {
|
||||
out = append(out, map[string]any{"type": "hardBreak"})
|
||||
} else if node.SoftLineBreak() {
|
||||
out = append(out, textNode(" ", marks))
|
||||
}
|
||||
case *ast.String:
|
||||
if len(node.Value) > 0 {
|
||||
out = append(out, textNode(string(node.Value), marks))
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
if t := rawText(node, src); t != "" {
|
||||
out = append(out, textNode(t, withMark(marks, mark("code"))))
|
||||
}
|
||||
case *ast.Emphasis:
|
||||
m := "em"
|
||||
if node.Level == 2 {
|
||||
m = "strong"
|
||||
}
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...)
|
||||
case *extast.Strikethrough:
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...)
|
||||
case *ast.Link:
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...)
|
||||
case *ast.AutoLink:
|
||||
if u := string(node.URL(src)); u != "" {
|
||||
out = append(out, textNode(u, withMark(marks, linkMark(u))))
|
||||
}
|
||||
default:
|
||||
out = append(out, inlineChildren(c, src, marks)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func textNode(s string, marks []any) map[string]any {
|
||||
tn := map[string]any{"type": "text", "text": s}
|
||||
if len(marks) > 0 {
|
||||
tn["marks"] = marks
|
||||
}
|
||||
return tn
|
||||
}
|
||||
|
||||
func mark(typ string) any { return map[string]any{"type": typ} }
|
||||
|
||||
func linkMark(href string) any {
|
||||
return map[string]any{"type": "link", "attrs": map[string]any{"href": href}}
|
||||
}
|
||||
|
||||
func withMark(marks []any, m any) []any {
|
||||
out := make([]any, 0, len(marks)+1)
|
||||
out = append(out, marks...)
|
||||
return append(out, m)
|
||||
}
|
||||
|
||||
func rawText(n ast.Node, src []byte) string {
|
||||
var b strings.Builder
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch t := c.(type) {
|
||||
case *ast.Text:
|
||||
b.Write(t.Segment.Value(src))
|
||||
case *ast.String:
|
||||
b.Write(t.Value)
|
||||
default:
|
||||
b.WriteString(rawText(c, src))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func codeText(n ast.Node, src []byte) string {
|
||||
var b strings.Builder
|
||||
lines := n.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
seg := lines.At(i)
|
||||
b.Write(seg.Value(src))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
85
pkg/templating/markdownrenderer/adf/adf_test.go
Normal file
85
pkg/templating/markdownrenderer/adf/adf_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package adf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func toJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderInlineMarks(t *testing.T) {
|
||||
js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)"))
|
||||
assert.Contains(t, js, `"type":"strong"`)
|
||||
assert.Contains(t, js, `"type":"em"`)
|
||||
assert.Contains(t, js, `"type":"code"`)
|
||||
assert.Contains(t, js, `"type":"link"`)
|
||||
assert.Contains(t, js, `"href":"https://x.io"`)
|
||||
assert.Contains(t, js, `"text":"bold"`)
|
||||
}
|
||||
|
||||
func TestRenderHeadingAndList(t *testing.T) {
|
||||
js := toJSON(t, Render("# Title\n\n- a\n- b"))
|
||||
assert.Contains(t, js, `"type":"heading"`)
|
||||
assert.Contains(t, js, `"level":1`)
|
||||
assert.Contains(t, js, `"type":"bulletList"`)
|
||||
assert.Contains(t, js, `"type":"listItem"`)
|
||||
}
|
||||
|
||||
func TestRenderOrderedList(t *testing.T) {
|
||||
js := toJSON(t, Render("1. one\n2. two"))
|
||||
assert.Contains(t, js, `"type":"orderedList"`)
|
||||
}
|
||||
|
||||
func TestRenderCodeBlock(t *testing.T) {
|
||||
js := toJSON(t, Render("```go\nx := 1\n```"))
|
||||
assert.Contains(t, js, `"type":"codeBlock"`)
|
||||
assert.Contains(t, js, `"language":"go"`)
|
||||
assert.Contains(t, js, `x := 1`)
|
||||
}
|
||||
|
||||
func TestRenderStrikethrough(t *testing.T) {
|
||||
js := toJSON(t, Render("~~gone~~"))
|
||||
assert.Contains(t, js, `"type":"strike"`)
|
||||
assert.Contains(t, js, `"text":"gone"`)
|
||||
}
|
||||
|
||||
func TestRenderBlockquote(t *testing.T) {
|
||||
js := toJSON(t, Render("> quoted"))
|
||||
assert.Contains(t, js, `"type":"blockquote"`)
|
||||
assert.Contains(t, js, `"text":"quoted"`)
|
||||
}
|
||||
|
||||
func TestRenderAutoLink(t *testing.T) {
|
||||
js := toJSON(t, Render("see <https://signoz.io>"))
|
||||
assert.Contains(t, js, `"type":"link"`)
|
||||
assert.Contains(t, js, `"href":"https://signoz.io"`)
|
||||
assert.Contains(t, js, `"text":"https://signoz.io"`)
|
||||
}
|
||||
|
||||
func TestRenderLineBreaks(t *testing.T) {
|
||||
js := toJSON(t, Render("one \ntwo"))
|
||||
assert.Contains(t, js, `"type":"hardBreak"`)
|
||||
|
||||
// a soft break renders as a space, keeping the paragraph intact
|
||||
js = toJSON(t, Render("one\ntwo"))
|
||||
assert.NotContains(t, js, `"type":"hardBreak"`)
|
||||
assert.Contains(t, js, `"text":" "`)
|
||||
}
|
||||
|
||||
func TestRenderPlainText(t *testing.T) {
|
||||
js := toJSON(t, Render("just text"))
|
||||
assert.Contains(t, js, `"type":"paragraph"`)
|
||||
assert.Contains(t, js, `"text":"just text"`)
|
||||
}
|
||||
|
||||
func TestRenderEmptyIsEmpty(t *testing.T) {
|
||||
assert.Empty(t, Render(""))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
@@ -32,6 +33,11 @@ var (
|
||||
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
|
||||
},
|
||||
}
|
||||
plaintextPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
@@ -53,6 +59,14 @@ func RenderSlackMrkdwn(markdown string) (string, error) {
|
||||
return render(md, markdown, "Slack mrkdwn")
|
||||
}
|
||||
|
||||
// RenderPlainText converts markdown to plain text: no markers, links flattened
|
||||
// to "text (url)".
|
||||
func RenderPlainText(markdown string) (string, error) {
|
||||
md := plaintextPool.Get().(goldmark.Markdown)
|
||||
defer plaintextPool.Put(md)
|
||||
return render(md, markdown, "plain text")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
|
||||
301
pkg/templating/markdownrenderer/plaintext/plaintext.go
Normal file
301
pkg/templating/markdownrenderer/plaintext/plaintext.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Package plaintext provides a goldmark node renderer that emits plain text:
|
||||
// no markdown or HTML markers, and links flattened to "text (url)". It is used
|
||||
// for JSM Ops timeline notes, which render neither HTML nor markdown.
|
||||
package plaintext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender registers the plain-text node renderer plus the GFM extensions it
|
||||
// handles (tables, strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
|
||||
// concurrent Convert calls; callers pool one instance per goroutine.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlock)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderBlock)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderPassthrough)
|
||||
reg.Register(ast.KindImage, r.renderLink)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindString, r.renderString)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderPassthrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line between block-level elements.
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// The renderer is pooled; wipe any prefix stack left over from a prior
|
||||
// document (e.g. one that errored mid-walk) before starting fresh.
|
||||
r.prefixes = r.prefixes[:0]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderBlock separates block-level nodes (paragraph, heading, blockquote) from
|
||||
// their previous sibling with a blank line, emitting no markers of their own.
|
||||
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
_, _ = w.Write(line.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "- "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
_, _ = w.WriteString(url)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderPassthrough emits no markers; the node's children render as plain text
|
||||
// (used for emphasis/strong and strikethrough).
|
||||
func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderLink flattens links and images to "text (url)": children render the
|
||||
// label, then the destination is appended in parentheses on exit.
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
var dest []byte
|
||||
switch n := node.(type) {
|
||||
case *ast.Link:
|
||||
dest = n.Destination
|
||||
case *ast.Image:
|
||||
dest = n.Destination
|
||||
}
|
||||
if !entering && len(dest) > 0 {
|
||||
_, _ = fmt.Fprintf(w, " (%s)", dest)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
_, _ = w.Write(n.Segment.Value(source))
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.Write(node.(*ast.String).Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Drop inline raw HTML tags; a plain-text note should never carry markup.
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Drop block-level raw HTML for the same reason as inline raw HTML.
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
first := true
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
first = false
|
||||
cellFirst := true
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() != extensionast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
if !cellFirst {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
cellFirst = false
|
||||
_, _ = w.WriteString(extractPlainText(cc, source))
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText collects the text content of a node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := node.(type) {
|
||||
case *ast.Text:
|
||||
buf.Write(t.Segment.Value(source))
|
||||
case *ast.String:
|
||||
buf.Write(t.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
55
pkg/templating/markdownrenderer/plaintext/plaintext_test.go
Normal file
55
pkg/templating/markdownrenderer/plaintext/plaintext_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package plaintext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func render(t *testing.T, md string) string {
|
||||
t.Helper()
|
||||
var b []byte
|
||||
buf := bytesBuffer{&b}
|
||||
g := goldmark.New(goldmark.WithExtensions(Extender))
|
||||
require.NoError(t, g.Convert([]byte(md), &buf))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// bytesBuffer is a tiny io.Writer so the test needs no extra imports.
|
||||
type bytesBuffer struct{ b *[]byte }
|
||||
|
||||
func (w bytesBuffer) Write(p []byte) (int, error) {
|
||||
*w.b = append(*w.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestPlainText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"strips bold and italic", "**bold** and *italic*", "bold and italic"},
|
||||
{"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"},
|
||||
{"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"},
|
||||
{"strikethrough stripped", "~~gone~~", "gone"},
|
||||
{"inline code unwrapped", "run `foo bar`", "run foo bar"},
|
||||
{"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"},
|
||||
{"unordered list", "- a\n- b", "- a\n- b"},
|
||||
{"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"},
|
||||
{"nested list indents under parent", "- a\n - b", "- a\n - b"},
|
||||
{"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"},
|
||||
{"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"},
|
||||
{"autolink kept as bare url", "see <https://signoz.io>", "see https://signoz.io"},
|
||||
{"inline raw html dropped", "a <b>bold</b> word", "a bold word"},
|
||||
{"html block dropped", "before\n\n<div>markup</div>\n\nafter", "before\n\nafter"},
|
||||
{"hard break becomes newline", "one \ntwo", "one\ntwo"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, render(t, c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -216,13 +216,20 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed.
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
|
||||
@@ -70,15 +70,19 @@ type Config struct {
|
||||
// on Receiver, and extensions to customConfigsOf + isEmpty.
|
||||
type customReceiverConfigs struct {
|
||||
GoogleChat []*GoogleChatReceiverConfig
|
||||
Jira []*JiraReceiverConfig
|
||||
JSMOps []*JSMOpsReceiverConfig
|
||||
}
|
||||
|
||||
func (c customReceiverConfigs) isEmpty() bool {
|
||||
return len(c.GoogleChat) == 0
|
||||
return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0
|
||||
}
|
||||
|
||||
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
|
||||
return customReceiverConfigs{
|
||||
GoogleChat: receiver.GoogleChatConfigs,
|
||||
Jira: receiver.JiraConfigs,
|
||||
JSMOps: receiver.JSMOpsConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +191,8 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
|
||||
receivers[i] = &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
JiraConfigs: custom.Jira,
|
||||
JSMOpsConfigs: custom.JSMOps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +368,8 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
JiraConfigs: custom.Jira,
|
||||
JSMOpsConfigs: custom.JSMOps,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -441,6 +449,16 @@ func (c *Config) applyNativeDefaults() {
|
||||
gc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
for _, jc := range custom.Jira {
|
||||
if jc.HTTPConfig == nil {
|
||||
jc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
for _, jc := range custom.JSMOps {
|
||||
if jc.HTTPConfig == nil {
|
||||
jc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
pkg/types/alertmanagertypes/jira.go
Normal file
119
pkg/types/alertmanagertypes/jira.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
|
||||
|
||||
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
|
||||
// cloud id) instead of the site host; they are identified by their email domain.
|
||||
const (
|
||||
jiraCloudHostSuffix = ".atlassian.net"
|
||||
jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com"
|
||||
jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/"
|
||||
)
|
||||
|
||||
// Default templates for the issue title and body. The body is rendered to
|
||||
// markdown and then wrapped in the ADF status panel + deep-links by the notifier.
|
||||
const (
|
||||
DefaultJiraSummaryTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
|
||||
|
||||
DefaultJiraDescriptionTemplate = `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}
|
||||
{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
)
|
||||
|
||||
// JiraReceiverConfig is the SigNoz Jira receiver. Fields are declared explicitly
|
||||
// instead of embedding upstream config.JiraConfig because that type's own
|
||||
// UnmarshalYAML would reset our defaults and drop sibling fields on the yaml
|
||||
// round-trip. Only Jira Cloud (v3/ADF) is supported, so api_url is derived from Site.
|
||||
type JiraReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline"`
|
||||
|
||||
Site string `json:"site,omitempty" yaml:"site,omitempty"`
|
||||
Project string `json:"project,omitempty" yaml:"project,omitempty"`
|
||||
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"`
|
||||
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
|
||||
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"`
|
||||
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"`
|
||||
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"`
|
||||
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"`
|
||||
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"`
|
||||
}
|
||||
|
||||
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
type plain JiraReceiverConfig
|
||||
if err := unmarshal((*plain)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.ReopenDuration <= 0 {
|
||||
c.ReopenDuration = defaultJiraReopenDuration
|
||||
}
|
||||
// sub-minute windows truncate to 0 in the reopen JQL and silently disable
|
||||
// reopening, so reject them.
|
||||
if c.ReopenDuration < model.Duration(time.Minute) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m")
|
||||
}
|
||||
if c.Summary == "" {
|
||||
c.Summary = DefaultJiraSummaryTemplate
|
||||
}
|
||||
if c.Description == "" {
|
||||
c.Description = DefaultJiraDescriptionTemplate
|
||||
}
|
||||
|
||||
site := strings.TrimRight(strings.TrimSpace(c.Site), "/")
|
||||
u, err := url.Parse(site)
|
||||
if site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://<site>%s)", jiraCloudHostSuffix))
|
||||
}
|
||||
c.Site = site
|
||||
|
||||
if c.Project == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required")
|
||||
}
|
||||
if c.IssueType == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira issue_type is required")
|
||||
}
|
||||
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira requires basic auth (email + API token)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsServiceAccount reports whether the basic-auth user is an Atlassian service
|
||||
// account, identified by its email domain. Service accounts must go through the
|
||||
// api.atlassian.com gateway; personal API tokens use the site host directly.
|
||||
func (c *JiraReceiverConfig) IsServiceAccount() bool {
|
||||
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(c.HTTPConfig.BasicAuth.Username), jiraServiceAccountEmailDomain)
|
||||
}
|
||||
|
||||
// APIBaseURL returns the Jira Cloud REST v3 base URL: the api.atlassian.com
|
||||
// gateway when a cloud id is given (service accounts), else the site host.
|
||||
func (c *JiraReceiverConfig) APIBaseURL(cloudID string) string {
|
||||
if cloudID != "" {
|
||||
return fmt.Sprintf("%s%s/rest/api/3", jiraGatewayBaseURL, cloudID)
|
||||
}
|
||||
return fmt.Sprintf("%s/rest/api/3", strings.TrimRight(c.Site, "/"))
|
||||
}
|
||||
119
pkg/types/alertmanagertypes/jira_test.go
Normal file
119
pkg/types/alertmanagertypes/jira_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
|
||||
auth := ""
|
||||
if withAuth {
|
||||
auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}`
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`,
|
||||
site, project, issueType, auth,
|
||||
)
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigDefaults(t *testing.T) {
|
||||
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JiraConfigs, 1)
|
||||
|
||||
jc := r.JiraConfigs[0]
|
||||
assert.Equal(t, "https://acme.atlassian.net", jc.Site)
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", jc.APIBaseURL(""))
|
||||
assert.False(t, jc.SendResolved()) // default off when omitted, like other channels
|
||||
assert.Equal(t, defaultJiraReopenDuration, jc.ReopenDuration)
|
||||
assert.Equal(t, DefaultJiraSummaryTemplate, jc.Summary)
|
||||
assert.Equal(t, DefaultJiraDescriptionTemplate, jc.Description)
|
||||
|
||||
ch, err := NewChannelFromReceiver(r, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jira", ch.Type)
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigSendResolved(t *testing.T) {
|
||||
withSendResolved := func(v bool) string {
|
||||
return fmt.Sprintf(
|
||||
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","send_resolved":%t,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
|
||||
v,
|
||||
)
|
||||
}
|
||||
on, err := NewReceiver(withSendResolved(true))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, on.JiraConfigs[0].SendResolved())
|
||||
|
||||
off, err := NewReceiver(withSendResolved(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, off.JiraConfigs[0].SendResolved())
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) {
|
||||
withReopen := func(v string) string {
|
||||
return fmt.Sprintf(
|
||||
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
|
||||
v,
|
||||
)
|
||||
}
|
||||
r, err := NewReceiver(withReopen("1m"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration)
|
||||
|
||||
_, err = NewReceiver(withReopen("30s"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestJiraAPIBaseURL(t *testing.T) {
|
||||
c := &JiraReceiverConfig{Site: "https://acme.atlassian.net"}
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", c.APIBaseURL(""))
|
||||
assert.Equal(t, "https://api.atlassian.com/ex/jira/09851b38-1a40-4c01-a36a-0a9336293200/rest/api/3", c.APIBaseURL("09851b38-1a40-4c01-a36a-0a9336293200"))
|
||||
}
|
||||
|
||||
func TestJiraIsServiceAccount(t *testing.T) {
|
||||
withUser := func(username string) *JiraReceiverConfig {
|
||||
return &JiraReceiverConfig{HTTPConfig: &commoncfg.HTTPClientConfig{BasicAuth: &commoncfg.BasicAuth{Username: username}}}
|
||||
}
|
||||
assert.True(t, withUser("bot@serviceaccount.atlassian.com").IsServiceAccount())
|
||||
assert.True(t, withUser("Bot@ServiceAccount.Atlassian.Com").IsServiceAccount())
|
||||
assert.False(t, withUser("temp@signoz.io").IsServiceAccount())
|
||||
assert.False(t, (&JiraReceiverConfig{}).IsServiceAccount())
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigTrailingSlashSite(t *testing.T) {
|
||||
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://acme.atlassian.net", r.JiraConfigs[0].Site)
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", r.JiraConfigs[0].APIBaseURL(""))
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)},
|
||||
{"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)},
|
||||
{"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)},
|
||||
{"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)},
|
||||
{"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)},
|
||||
{"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)},
|
||||
{"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)},
|
||||
{"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := NewReceiver(c.json)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
75
pkg/types/alertmanagertypes/jsmops.go
Normal file
75
pkg/types/alertmanagertypes/jsmops.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a
|
||||
// single global host keyed by the integration API key (no region/cloud id in
|
||||
// the path). The trailing slash is required: the Opsgenie notifier appends
|
||||
// "v2/alerts..." to APIURL.Path with no separator.
|
||||
const JSMOpsAPIBaseURL = "https://api.atlassian.com/jsm/ops/integration/"
|
||||
|
||||
// JSM Ops speaks the Opsgenie alert API, so a JSM alert description takes the
|
||||
// same HTML subset and 15,000-char limit; message caps at 130. The templates
|
||||
// mirror Google Chat / Jira for a consistent default across channels.
|
||||
const (
|
||||
DefaultJSMOpsMessageTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
|
||||
|
||||
DefaultJSMOpsDescriptionTemplate = `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
|
||||
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
|
||||
|
||||
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`
|
||||
)
|
||||
|
||||
// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is
|
||||
// delivered by reusing the Opsgenie notifier (JSM Ops is the ex-Opsgenie alert
|
||||
// API): the notifier package maps these fields onto config.OpsGenieConfig with
|
||||
// APIURL pinned to JSMOpsAPIBaseURL.
|
||||
type JSMOpsReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
|
||||
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
Message string `yaml:"message,omitempty" json:"message,omitempty"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
// by the yaml round-trip to the request value (false when omitted); the UI sends
|
||||
// it explicitly, defaulted on, so JSM alerts close on resolve.
|
||||
var DefaultJSMOpsReceiverConfig = JSMOpsReceiverConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: false,
|
||||
},
|
||||
Message: DefaultJSMOpsMessageTemplate,
|
||||
Description: DefaultJSMOpsDescriptionTemplate,
|
||||
Tags: "signoz",
|
||||
}
|
||||
|
||||
func (c *JSMOpsReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
*c = DefaultJSMOpsReceiverConfig
|
||||
type plain JSMOpsReceiverConfig
|
||||
if err := unmarshal((*plain)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jsm ops api_key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
70
pkg/types/alertmanagertypes/jsmops_test.go
Normal file
70
pkg/types/alertmanagertypes/jsmops_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSMOpsReceiverConfigDefaults(t *testing.T) {
|
||||
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"key-123"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JSMOpsConfigs, 1)
|
||||
|
||||
c := r.JSMOpsConfigs[0]
|
||||
assert.Equal(t, "key-123", string(c.APIKey))
|
||||
assert.Equal(t, DefaultJSMOpsMessageTemplate, c.Message)
|
||||
assert.Equal(t, DefaultJSMOpsDescriptionTemplate, c.Description)
|
||||
assert.Equal(t, "signoz", c.Tags)
|
||||
assert.False(t, c.SendResolved()) // default off when omitted, like other channels
|
||||
|
||||
ch, err := NewChannelFromReceiver(r, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jsmops", ch.Type)
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigOverrides(t *testing.T) {
|
||||
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"k","message":"m","description":"d","priority":"P1","tags":"a,b","send_resolved":true}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JSMOpsConfigs, 1)
|
||||
|
||||
c := r.JSMOpsConfigs[0]
|
||||
assert.Equal(t, "m", c.Message)
|
||||
assert.Equal(t, "d", c.Description)
|
||||
assert.Equal(t, "P1", c.Priority)
|
||||
assert.Equal(t, "a,b", c.Tags)
|
||||
assert.True(t, c.SendResolved())
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigSendResolved(t *testing.T) {
|
||||
withSendResolved := func(v bool) string {
|
||||
return fmt.Sprintf(`{"name":"jsm","jsmops_configs":[{"api_key":"k","send_resolved":%t}]}`, v)
|
||||
}
|
||||
on, err := NewReceiver(withSendResolved(true))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, on.JSMOpsConfigs, 1)
|
||||
assert.True(t, on.JSMOpsConfigs[0].SendResolved())
|
||||
|
||||
off, err := NewReceiver(withSendResolved(false))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, off.JSMOpsConfigs, 1)
|
||||
assert.False(t, off.JSMOpsConfigs[0].SendResolved())
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"missing api_key", `{"name":"jsm","jsmops_configs":[{"message":"m"}]}`},
|
||||
{"empty api_key", `{"name":"jsm","jsmops_configs":[{"api_key":""}]}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := NewReceiver(c.json)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
type Receiver struct {
|
||||
*config.Receiver
|
||||
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
|
||||
// Shadows upstream's jira_configs so our custom notifier (rich ADF, deep-links,
|
||||
// lifecycle comments) handles it instead of upstream's plain Jira notifier.
|
||||
JiraConfigs []*JiraReceiverConfig `json:"jira_configs,omitempty" yaml:"jira_configs,omitempty"`
|
||||
// JSM Ops (ex-Opsgenie alert API); delivered by reusing the Opsgenie notifier.
|
||||
JSMOpsConfigs []*JSMOpsReceiverConfig `json:"jsmops_configs,omitempty" yaml:"jsmops_configs,omitempty"`
|
||||
}
|
||||
|
||||
// NewReceiver builds a Receiver from its JSON input, applying each notifier
|
||||
@@ -51,6 +56,22 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
receiver.GoogleChatConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
for i, jc := range receiver.JiraConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(jc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.JiraConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
for i, jc := range receiver.JSMOpsConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(jc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.JSMOpsConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
|
||||
10
tests/fixtures/alerts.py
vendored
10
tests/fixtures/alerts.py
vendored
@@ -143,6 +143,16 @@ def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: st
|
||||
raise AssertionError(f"No firing entry recorded in rule state history within {wait_seconds}s, items: {items}")
|
||||
|
||||
|
||||
def get_rule(signoz: types.SigNoz, token: str, rule_id: str) -> dict:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"Failed to get rule, api returned {response.status_code} with response: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
|
||||
def get_rule_history_top_contributors(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, end_ms: int) -> list[dict]:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}/history/top_contributors"),
|
||||
|
||||
12
tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl
vendored
Normal file
12
tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:01:00+00:00","value":1,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:02:00+00:00","value":2,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:03:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:04:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:06:00+00:00","value":10,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:07:00+00:00","value":36,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:08:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:09:00+00:00","value":37,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:10:00+00:00","value":35,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:11:00+00:00","value":39,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:12:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
59
tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json
vendored
Normal file
59
tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"alert": "disabled_rule",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"disabled": true,
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 10,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "clickhouse_sql",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "WITH __temporal_aggregation_cte AS (\n SELECT \n fingerprint, \n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, \n avg(value) AS per_series_value \n FROM signoz_metrics.distributed_samples_v4 AS points \n INNER JOIN (\n SELECT fingerprint \n FROM signoz_metrics.time_series_v4 \n WHERE metric_name IN ('request_total_disabled_rule') \n AND LOWER(temporality) LIKE LOWER('cumulative') \n GROUP BY fingerprint\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint \n WHERE metric_name IN ('request_total_disabled_rule') \n AND unix_milli >= $start_timestamp_ms \n AND unix_milli < $end_timestamp_ms \n GROUP BY fingerprint, ts \n ORDER BY fingerprint, ts\n), \n__spatial_aggregation_cte AS (\n SELECT \n ts, \n sum(per_series_value) AS value \n FROM __temporal_aggregation_cte \n WHERE isNaN(per_series_value) = 0 \n GROUP BY ts\n) \nSELECT * FROM __spatial_aggregation_cte \nORDER BY ts"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
109
tests/integration/tests/alerts/05_disabled_rule.py
Normal file
109
tests/integration/tests/alerts/05_disabled_rule.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
collect_webhook_firing_alerts,
|
||||
get_rule,
|
||||
update_rule_channel_name,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The rule evaluates every 15s and the alert data is set up to fire on the
|
||||
# first evaluation, so a buggy evaluator would transition the rule and fire
|
||||
# well within this window.
|
||||
OBSERVATION_WINDOW_SECONDS = 35
|
||||
|
||||
|
||||
def test_disabled_rule_does_not_evaluate_or_notify(
|
||||
signoz: types.SigNoz,
|
||||
# Notification channel related fixtures
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
|
||||
# Alert rule related fixtures
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
# Alert data insertion related fixtures
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
A rule created with disabled: true must not be evaluated: its state must
|
||||
stay "disabled" and it must not send any notification, even though the
|
||||
inserted data would fire the rule if it were evaluated. The companion
|
||||
scenario threshold_above_at_least_once in 02_basic_alert_conditions.py
|
||||
uses the same data shape and fires when the rule is enabled.
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Prepare notification channel name and webhook endpoint
|
||||
notification_channel_name = str(uuid.uuid4())
|
||||
webhook_endpoint_path = f"/alert/{notification_channel_name}"
|
||||
notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path)
|
||||
|
||||
# register the mock endpoint in notification channel
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url=webhook_endpoint_path,
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Create an alert channel using the given route
|
||||
create_webhook_notification_channel(
|
||||
channel_name=notification_channel_name,
|
||||
webhook_url=notification_url,
|
||||
http_config={},
|
||||
send_resolved=False,
|
||||
)
|
||||
|
||||
# Insert alert data that would fire the rule if it were evaluated
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="metrics", data_path="alerts/test_scenarios/disabled_rule/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
|
||||
# Create the disabled alert rule
|
||||
rule_path = get_testdata_file_path("alerts/test_scenarios/disabled_rule/rule.json")
|
||||
with open(rule_path, encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, notification_channel_name)
|
||||
rule_id = create_alert_rule(rule_data)
|
||||
logger.info(
|
||||
"disabled rule created with id: %s",
|
||||
{"rule_id": rule_id, "rule_name": rule_data["alert"]},
|
||||
)
|
||||
|
||||
# The rule must stay disabled and must not fire for the whole observation
|
||||
# window; poll to give a buggy evaluator several chances to run.
|
||||
deadline = time.time() + OBSERVATION_WINDOW_SECONDS
|
||||
while time.time() < deadline:
|
||||
rule = get_rule(signoz, token, rule_id)
|
||||
assert rule["state"] == "disabled", f"disabled rule transitioned to unexpected state: {rule['state']}"
|
||||
assert rule["disabled"] is True, "disabled rule was unexpectedly re-enabled"
|
||||
|
||||
firing_alerts = collect_webhook_firing_alerts(notification_channel, notification_channel_name)
|
||||
assert len(firing_alerts) == 0, f"disabled rule fired alerts: {[alert.labels for alert in firing_alerts]}"
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("disabled rule stayed disabled and sent no notifications, as expected")
|
||||
Reference in New Issue
Block a user