mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-26 03:40:33 +01:00
Compare commits
9 Commits
mute-rules
...
emdash/tra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd9889226 | ||
|
|
51da3e0d72 | ||
|
|
a004ba8d06 | ||
|
|
e41b46bbb4 | ||
|
|
15ac97f49f | ||
|
|
f6971c8f9f | ||
|
|
72c65d7dd9 | ||
|
|
7a88dbabdd | ||
|
|
bb471848cc |
@@ -5109,23 +5109,6 @@ components:
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
RuletypesActiveMuteInfo:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
effectiveEndTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
effectiveStartTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesAlertCompositeQuery:
|
||||
properties:
|
||||
panelType:
|
||||
@@ -5376,8 +5359,6 @@ components:
|
||||
type: object
|
||||
RuletypesRule:
|
||||
properties:
|
||||
activeMute:
|
||||
$ref: '#/components/schemas/RuletypesActiveMuteInfo'
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
@@ -18967,6 +18948,77 @@ paths:
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Two-step fetch: minimal fields for all spans to build the tree,
|
||||
full fields only for the visible window. Aggregations are not included in
|
||||
the response.'
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get waterfall view for a trace (OOM-safe)
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -89,6 +89,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -6082,31 +6082,6 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
state: RuletypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export interface RuletypesActiveMuteInfoDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveEndTime?: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveStartTime?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesPanelTypeDTO {
|
||||
value = 'value',
|
||||
table = 'table',
|
||||
@@ -6431,7 +6406,6 @@ export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export interface RuletypesRuleDTO {
|
||||
activeMute?: RuletypesActiveMuteInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9258,6 +9232,17 @@ export type GetWaterfall200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallV4PathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfallV4200 = {
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -120,3 +122,102 @@ export const useGetWaterfall = <
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Two-step fetch: minimal fields for all spans to build the tree, full fields only for the visible window. Aggregations are not included in the response.
|
||||
* @summary Get waterfall view for a trace (OOM-safe)
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
{ traceID }: GetWaterfallV4PathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfallV4200>({
|
||||
url: `/api/v4/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallV4MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfallV4'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfallV4(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallV4MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>
|
||||
>;
|
||||
export type GetWaterfallV4MutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallV4MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace (OOM-safe)
|
||||
*/
|
||||
export const useGetWaterfallV4 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetWaterfallV4MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -11,4 +11,5 @@ export enum FeatureKeys {
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import DeleteAlert from './DeleteAlert';
|
||||
import { ColumnButton, SearchContainer } from './styles';
|
||||
import MutedBadge from './TableComponents/MutedBadge';
|
||||
import Status from './TableComponents/Status';
|
||||
import ToggleAlertState from './ToggleAlertState';
|
||||
import { alertActionLogEvent, filterAlerts } from './utils';
|
||||
@@ -277,14 +276,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
|
||||
};
|
||||
|
||||
const muteEndTime = record.activeMute?.effectiveEndTime ?? undefined;
|
||||
|
||||
return (
|
||||
<span className="alert-list-name-cell">
|
||||
<Typography.Link onClick={onClickHandler}>{value}</Typography.Link>
|
||||
<MutedBadge muteEndTime={muteEndTime} />
|
||||
</span>
|
||||
);
|
||||
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
|
||||
},
|
||||
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
|
||||
},
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.alert-list-name-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert-list-muted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-amber-500);
|
||||
background: rgba(255, 205, 86, 0.12);
|
||||
border: 1px solid rgba(255, 205, 86, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { BellOff } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import './MutedBadge.styles.scss';
|
||||
|
||||
const formatRemaining = (endTime: string | undefined): string | null => {
|
||||
if (!endTime) {
|
||||
return null;
|
||||
}
|
||||
const end = dayjs(endTime);
|
||||
const now = dayjs();
|
||||
const diffMs = end.diff(now);
|
||||
if (diffMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
const totalMinutes = Math.floor(diffMs / 60000);
|
||||
const days = Math.floor(totalMinutes / (60 * 24));
|
||||
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
interface MutedBadgeProps {
|
||||
muteEndTime?: string;
|
||||
}
|
||||
|
||||
function MutedBadge({ muteEndTime }: MutedBadgeProps): JSX.Element | null {
|
||||
if (!muteEndTime) {
|
||||
return null;
|
||||
}
|
||||
const remaining = formatRemaining(muteEndTime);
|
||||
return (
|
||||
<span className="alert-list-muted-badge">
|
||||
<BellOff size={10} />
|
||||
<span>MUTED{remaining ? ` · ${remaining}` : ''}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default MutedBadge;
|
||||
@@ -12,20 +12,6 @@
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-state-segmented-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.alert-state-segmented-anchor {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dropdown-menu {
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -16,13 +17,6 @@ import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { AlertHeaderProps } from '../AlertHeader';
|
||||
import AlertStateSegmented, {
|
||||
AlertSegmentedState,
|
||||
} from '../MuteAlert/AlertStateSegmented';
|
||||
import MutePopover from '../MuteAlert/MutePopover';
|
||||
import MuteSchedulerDrawer from '../MuteAlert/MuteSchedulerDrawer';
|
||||
import { useActiveMute } from '../MuteAlert/useActiveMute';
|
||||
import { useMuteAlertRule } from '../MuteAlert/useMuteAlertRule';
|
||||
import RenameModal from './RenameModal';
|
||||
|
||||
import './ActionButtons.styles.scss';
|
||||
@@ -129,77 +123,19 @@ function AlertActionButtons({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => (): void => setAlertRuleState(undefined), []);
|
||||
|
||||
const { activeMute, refetch: refetchActiveMute } = useActiveMute(ruleId);
|
||||
|
||||
const segmentedState: AlertSegmentedState = useMemo(() => {
|
||||
if (isAlertRuleDisabled) {
|
||||
return 'disabled';
|
||||
}
|
||||
if (activeMute) {
|
||||
return 'muted';
|
||||
}
|
||||
return 'active';
|
||||
}, [isAlertRuleDisabled, activeMute]);
|
||||
|
||||
const [isMutePopoverOpen, setIsMutePopoverOpen] = useState<boolean>(false);
|
||||
const [isMuteDrawerOpen, setIsMuteDrawerOpen] = useState<boolean>(false);
|
||||
|
||||
const { mute, isLoading: isMuting } = useMuteAlertRule({
|
||||
ruleId,
|
||||
onSuccess: () => {
|
||||
setIsMutePopoverOpen(false);
|
||||
setIsMuteDrawerOpen(false);
|
||||
refetchActiveMute();
|
||||
},
|
||||
});
|
||||
|
||||
const handleActiveClick = useCallback(() => {
|
||||
// If currently disabled, re-enable. Otherwise (already active) no-op.
|
||||
// When muted, the segmented control disables this button.
|
||||
if (isAlertRuleDisabled) {
|
||||
setIsAlertRuleDisabled(false);
|
||||
handleAlertStateToggle();
|
||||
}
|
||||
}, [isAlertRuleDisabled, handleAlertStateToggle]);
|
||||
|
||||
const handleMuteClick = useCallback(() => {
|
||||
if (segmentedState === 'active') {
|
||||
setIsMutePopoverOpen(true);
|
||||
}
|
||||
}, [segmentedState]);
|
||||
|
||||
const handleDisableClick = useCallback(() => {
|
||||
if (!isAlertRuleDisabled) {
|
||||
setIsAlertRuleDisabled(true);
|
||||
handleAlertStateToggle();
|
||||
}
|
||||
}, [isAlertRuleDisabled, handleAlertStateToggle]);
|
||||
|
||||
const ruleDisplayName = alertRuleName ?? alertDetails.alert;
|
||||
const toggleAlertRule = useCallback(() => {
|
||||
setIsAlertRuleDisabled((prev) => !prev);
|
||||
handleAlertStateToggle();
|
||||
}, [handleAlertStateToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="alert-action-buttons">
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<div className="alert-state-segmented-wrapper">
|
||||
<AlertStateSegmented
|
||||
state={segmentedState}
|
||||
onActive={handleActiveClick}
|
||||
onMute={handleMuteClick}
|
||||
onDisable={handleDisableClick}
|
||||
/>
|
||||
<MutePopover
|
||||
open={isMutePopoverOpen}
|
||||
onOpenChange={setIsMutePopoverOpen}
|
||||
ruleName={ruleDisplayName}
|
||||
isLoading={isMuting}
|
||||
onSubmit={mute}
|
||||
onOpenCustomWindow={(): void => setIsMuteDrawerOpen(true)}
|
||||
anchor={<span className="alert-state-segmented-anchor" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" />
|
||||
@@ -216,14 +152,6 @@ function AlertActionButtons({
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<MuteSchedulerDrawer
|
||||
open={isMuteDrawerOpen}
|
||||
onClose={(): void => setIsMuteDrawerOpen(false)}
|
||||
ruleName={ruleDisplayName}
|
||||
isLoading={isMuting}
|
||||
onSubmit={mute}
|
||||
/>
|
||||
|
||||
<RenameModal
|
||||
isOpen={isRenameAlertOpen}
|
||||
setIsOpen={setIsRenameAlertOpen}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
.alert-info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.alert-info__banner {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -12,9 +12,6 @@ import AlertActionButtons from './ActionButtons/ActionButtons';
|
||||
import AlertLabels from './AlertLabels/AlertLabels';
|
||||
import AlertSeverity from './AlertSeverity/AlertSeverity';
|
||||
import AlertState from './AlertState/AlertState';
|
||||
import DisabledBanner from './MuteAlert/DisabledBanner';
|
||||
import MutedBanner from './MuteAlert/MutedBanner';
|
||||
import { useActiveMute } from './MuteAlert/useActiveMute';
|
||||
|
||||
import './AlertHeader.styles.scss';
|
||||
|
||||
@@ -46,13 +43,6 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const ruleId = alertDetails?.id || '';
|
||||
const { activeMute } = useActiveMute(ruleId);
|
||||
const effectiveState = alertRuleState ?? state ?? '';
|
||||
const isDisabled = effectiveState === 'disabled';
|
||||
const showMutedBanner = !isDisabled && Boolean(activeMute);
|
||||
const showDisabledBanner = isDisabled;
|
||||
|
||||
const CreateAlertV1Header = (
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
@@ -77,23 +67,14 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-info-wrapper">
|
||||
<div className="alert-info">
|
||||
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
|
||||
<div className="alert-info__action-buttons">
|
||||
<AlertActionButtons alertDetails={alertDetails} ruleId={ruleId} />
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
|
||||
<div className="alert-info__action-buttons">
|
||||
<AlertActionButtons
|
||||
alertDetails={alertDetails}
|
||||
ruleId={alertDetails?.id || ''}
|
||||
/>
|
||||
</div>
|
||||
{showMutedBanner && activeMute && (
|
||||
<div className="alert-info__banner">
|
||||
<MutedBanner activeMute={activeMute} />
|
||||
</div>
|
||||
)}
|
||||
{showDisabledBanner && (
|
||||
<div className="alert-info__banner">
|
||||
<DisabledBanner rule={alertDetails as RuletypesRuleDTO} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
.alert-state-segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 999px;
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms,
|
||||
color 140ms;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-ink-200);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--active-active {
|
||||
background: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-robin-600);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&.alert-state-segmented__pill--active-muted {
|
||||
background: var(--bg-amber-500);
|
||||
color: #1a1407;
|
||||
|
||||
.alert-state-segmented__icon,
|
||||
.alert-state-segmented__label {
|
||||
color: #1a1407;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-amber-600);
|
||||
}
|
||||
}
|
||||
|
||||
&--active-disabled {
|
||||
background: var(--bg-slate-100);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-slate-200);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--bg-vanilla-100);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-state-segmented {
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-slate-500);
|
||||
|
||||
&__pill {
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { BellOff } from '@signozhq/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import './AlertStateSegmented.styles.scss';
|
||||
|
||||
export type AlertSegmentedState = 'active' | 'muted' | 'disabled';
|
||||
|
||||
export interface AlertStateSegmentedProps {
|
||||
state: AlertSegmentedState;
|
||||
onActive: () => void;
|
||||
onMute: () => void;
|
||||
onDisable: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AlertStateSegmented = forwardRef<
|
||||
HTMLDivElement,
|
||||
AlertStateSegmentedProps
|
||||
>(function AlertStateSegmented(props, ref): JSX.Element {
|
||||
const { state, onActive, onMute, onDisable, disabled } = props;
|
||||
|
||||
const isMuted = state === 'muted';
|
||||
const isDisabled = state === 'disabled';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="alert-state-segmented"
|
||||
role="tablist"
|
||||
aria-label="Alert rule state"
|
||||
ref={ref}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={state === 'active'}
|
||||
aria-label="Active"
|
||||
className={classNames('alert-state-segmented__pill', {
|
||||
'alert-state-segmented__pill--active-active': state === 'active',
|
||||
})}
|
||||
onClick={onActive}
|
||||
// Per spec: when muted, un-muting must happen via Planned Downtimes,
|
||||
// so the Active pill is non-interactive while muted.
|
||||
disabled={disabled || isMuted}
|
||||
>
|
||||
{state === 'active' && (
|
||||
<span className="alert-state-segmented__dot" aria-hidden />
|
||||
)}
|
||||
<span className="alert-state-segmented__label">Active</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={state === 'muted'}
|
||||
aria-label="Mute"
|
||||
className={classNames('alert-state-segmented__pill', {
|
||||
'alert-state-segmented__pill--active-muted': state === 'muted',
|
||||
})}
|
||||
onClick={onMute}
|
||||
// Muting a disabled rule wouldn't change observable behavior, so the
|
||||
// Mute pill is non-interactive while disabled.
|
||||
disabled={disabled || isDisabled}
|
||||
>
|
||||
{state === 'muted' && (
|
||||
<BellOff size={12} className="alert-state-segmented__icon" />
|
||||
)}
|
||||
<span className="alert-state-segmented__label">Mute</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={state === 'disabled'}
|
||||
aria-label="Disable"
|
||||
className={classNames('alert-state-segmented__pill', {
|
||||
'alert-state-segmented__pill--active-disabled': state === 'disabled',
|
||||
})}
|
||||
onClick={onDisable}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="alert-state-segmented__label">Disable</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AlertStateSegmented;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { CircleOff } from '@signozhq/icons';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import './StateBanners.styles.scss';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface DisabledBannerProps {
|
||||
rule: RuletypesRuleDTO;
|
||||
}
|
||||
|
||||
function DisabledBanner({ rule }: DisabledBannerProps): JSX.Element {
|
||||
const updatedAt = rule.updatedAt ? dayjs(rule.updatedAt) : null;
|
||||
|
||||
return (
|
||||
<div className="state-banner state-banner--disabled" role="status">
|
||||
<div className="state-banner__icon-disc state-banner__icon-disc--disabled">
|
||||
<CircleOff size={18} color="var(--bg-slate-50)" />
|
||||
</div>
|
||||
<div className="state-banner__body">
|
||||
<div className="state-banner__title">
|
||||
<span>Rule disabled</span>
|
||||
<span className="state-banner__pill state-banner__pill--disabled">
|
||||
NOT EVALUATING
|
||||
</span>
|
||||
</div>
|
||||
<div className="state-banner__meta">
|
||||
<span>Evaluation paused — no fires will be recorded.</span>
|
||||
{updatedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
<span>{updatedAt.fromNow()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisabledBanner;
|
||||
@@ -1,193 +0,0 @@
|
||||
.mute-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
padding: 0;
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mute-popover {
|
||||
width: 320px;
|
||||
padding: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 140ms,
|
||||
background 140ms;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
strong {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__cell {
|
||||
padding: 9px 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms,
|
||||
border-color 140ms,
|
||||
color 140ms;
|
||||
|
||||
&:hover:not(&--selected) {
|
||||
background: rgba(78, 116, 248, 0.08);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--bg-vanilla-100);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--bg-slate-200);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms,
|
||||
background 140ms;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
margin: 12px 0;
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__input.ant-input {
|
||||
padding: 8px 10px;
|
||||
font-size: 12.5px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 6px;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms,
|
||||
color 140ms;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-robin-500);
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-robin-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BellOff, Calendar, X } from '@signozhq/icons';
|
||||
import { Input, Popover } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
import type { MutePayload } from './useMuteAlertRule';
|
||||
|
||||
import './MutePopover.styles.scss';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type QuickDuration = {
|
||||
label: string;
|
||||
value: string;
|
||||
minutes: number | null; // null = forever
|
||||
};
|
||||
|
||||
export const QUICK_DURATIONS: QuickDuration[] = [
|
||||
{ label: '15 min', value: '15m', minutes: 15 },
|
||||
{ label: '1 hour', value: '1h', minutes: 60 },
|
||||
{ label: '4 hours', value: '4h', minutes: 240 },
|
||||
{ label: '1 day', value: '1d', minutes: 60 * 24 },
|
||||
{ label: '1 week', value: '1w', minutes: 60 * 24 * 7 },
|
||||
{ label: 'Forever', value: 'forever', minutes: null },
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION_VALUE = '4h';
|
||||
|
||||
export const buildMutePayloadFromQuickDuration = (
|
||||
durationValue: string,
|
||||
name: string,
|
||||
): MutePayload | null => {
|
||||
const duration = QUICK_DURATIONS.find((d) => d.value === durationValue);
|
||||
if (!duration) {
|
||||
return null;
|
||||
}
|
||||
const now = dayjs();
|
||||
const startTime = now.toISOString();
|
||||
// duration.minutes === null → "Forever"; send endTime as null so the
|
||||
// backend treats the mute as indefinite.
|
||||
const endTime =
|
||||
duration.minutes === null
|
||||
? null
|
||||
: now.add(duration.minutes, 'minute').toISOString();
|
||||
return {
|
||||
name,
|
||||
startTime,
|
||||
endTime,
|
||||
timezone: dayjs.tz.guess?.() || 'UTC',
|
||||
};
|
||||
};
|
||||
|
||||
const getDefaultMuteName = (ruleName: string | undefined): string =>
|
||||
ruleName ? `Muted: ${ruleName}` : 'Muted alert';
|
||||
|
||||
interface MutePopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
anchor: React.ReactNode;
|
||||
ruleName: string | undefined;
|
||||
isLoading: boolean;
|
||||
onSubmit: (payload: MutePayload) => Promise<void> | void;
|
||||
onOpenCustomWindow: () => void;
|
||||
}
|
||||
|
||||
function MutePopover(props: MutePopoverProps): JSX.Element {
|
||||
const {
|
||||
open,
|
||||
onOpenChange,
|
||||
anchor,
|
||||
ruleName,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
onOpenCustomWindow,
|
||||
} = props;
|
||||
|
||||
const [selected, setSelected] = useState<string>(DEFAULT_DURATION_VALUE);
|
||||
const [name, setName] = useState<string>(getDefaultMuteName(ruleName));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(DEFAULT_DURATION_VALUE);
|
||||
setName(getDefaultMuteName(ruleName));
|
||||
}
|
||||
}, [open, ruleName]);
|
||||
|
||||
// Close on outside click / Escape. We use trigger={[]} on the Popover so
|
||||
// antd doesn't handle these — without this hook, the popover only closes
|
||||
// via Cancel / × / Mute submit.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Drop focus so the trigger button doesn't show a :focus-visible
|
||||
// outline after the popover closes via Escape / outside click.
|
||||
const closeAndBlur = (): void => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('.mute-popover-overlay')) {
|
||||
return;
|
||||
}
|
||||
closeAndBlur();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAndBlur();
|
||||
}
|
||||
};
|
||||
|
||||
// Defer attaching listeners until after the click that opened the
|
||||
// popover has finished bubbling — otherwise it counts as an outside
|
||||
// click and we close immediately.
|
||||
const timer = window.setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
}, 0);
|
||||
|
||||
return (): void => {
|
||||
window.clearTimeout(timer);
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const selectedDuration = QUICK_DURATIONS.find((d) => d.value === selected);
|
||||
const primaryLabel =
|
||||
selectedDuration?.minutes === null
|
||||
? 'Mute indefinitely'
|
||||
: `Mute for ${selectedDuration?.label.toLowerCase() ?? '4 hours'}`;
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const payload = buildMutePayloadFromQuickDuration(selected, name.trim());
|
||||
if (!payload || !payload.name) {
|
||||
return;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="mute-popover"
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mute-popover__header">
|
||||
<div className="mute-popover__title">
|
||||
<BellOff size={14} />
|
||||
<span>Mute notifications</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="mute-popover__close"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mute-popover__hint">
|
||||
Rule keeps evaluating in the background. You'll still see fires in{' '}
|
||||
<strong>History</strong> — just no pages, Slack, or email.
|
||||
</p>
|
||||
|
||||
<div className="mute-popover__grid">
|
||||
{QUICK_DURATIONS.map((d) => (
|
||||
<button
|
||||
type="button"
|
||||
key={d.value}
|
||||
className={classNames('mute-popover__cell', {
|
||||
'mute-popover__cell--selected': selected === d.value,
|
||||
})}
|
||||
onClick={(): void => setSelected(d.value)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mute-popover__custom"
|
||||
onClick={(): void => {
|
||||
onOpenChange(false);
|
||||
onOpenCustomWindow();
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} />
|
||||
Custom window…
|
||||
</button>
|
||||
|
||||
<div className="mute-popover__divider" />
|
||||
|
||||
<label className="mute-popover__label" htmlFor="mute-popover-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="mute-popover-name"
|
||||
className="mute-popover__input"
|
||||
placeholder="e.g. Deployment window"
|
||||
value={name}
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
maxLength={120}
|
||||
/>
|
||||
|
||||
<div className="mute-popover__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="mute-popover__btn mute-popover__btn--ghost"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mute-popover__btn mute-popover__btn--primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
<BellOff size={12} />
|
||||
{primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={[]}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
destroyTooltipOnHide
|
||||
overlayClassName="mute-popover-overlay"
|
||||
content={content}
|
||||
>
|
||||
{anchor}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MutePopover;
|
||||
@@ -1,115 +0,0 @@
|
||||
.mute-scheduler-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 24px 28px;
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
.ant-drawer-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms,
|
||||
color 140ms;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin: 8px 0 14px 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
strong {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
margin: 0 0 16px 0;
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&__form {
|
||||
.ant-form-item-label > label {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 4px 0 18px 0;
|
||||
padding: 10px;
|
||||
background: rgba(35, 196, 248, 0.06);
|
||||
border: 1px solid rgba(35, 196, 248, 0.2);
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
strong {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BellOff, Check, Info } from '@signozhq/icons';
|
||||
import { Button, DatePicker, Drawer, Form, Input, Select } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import {
|
||||
recurrenceOptions,
|
||||
recurrenceOptionWithSubmenu,
|
||||
recurrenceWeeklyOptions,
|
||||
} from 'container/PlannedDowntime/PlannedDowntimeutils';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
|
||||
|
||||
import type { MutePayload } from './useMuteAlertRule';
|
||||
|
||||
import './MuteSchedulerDrawer.styles.scss';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
|
||||
const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map((tz) => ({
|
||||
label: tz,
|
||||
value: tz,
|
||||
key: tz,
|
||||
}));
|
||||
|
||||
const DURATION_UNIT_OPTIONS = [
|
||||
{ label: 'Mins', value: 'm' },
|
||||
{ label: 'Hours', value: 'h' },
|
||||
];
|
||||
|
||||
type MuteSchedulerFormData = {
|
||||
name: string;
|
||||
startTime: dayjs.Dayjs | null;
|
||||
endTime: dayjs.Dayjs | null;
|
||||
repeatType: string;
|
||||
repeatOn?: string[];
|
||||
duration?: number;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
interface MuteSchedulerDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ruleName: string | undefined;
|
||||
isLoading: boolean;
|
||||
onSubmit: (payload: MutePayload) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function MuteSchedulerDrawer(props: MuteSchedulerDrawerProps): JSX.Element {
|
||||
const { open, onClose, ruleName, isLoading, onSubmit } = props;
|
||||
const [form] = Form.useForm<MuteSchedulerFormData>();
|
||||
const [recurrenceType, setRecurrenceType] = useState<string>(
|
||||
recurrenceOptions.doesNotRepeat.value,
|
||||
);
|
||||
const [durationUnit, setDurationUnit] = useState<string>('m');
|
||||
|
||||
const defaultName = useMemo(
|
||||
() => (ruleName ? `Muted: ${ruleName}` : 'Muted alert'),
|
||||
[ruleName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const guess = (dayjs as any).tz?.guess?.() || 'UTC';
|
||||
form.setFieldsValue({
|
||||
name: defaultName,
|
||||
startTime: dayjs(),
|
||||
endTime: dayjs().add(1, 'hour'),
|
||||
repeatType: recurrenceOptions.doesNotRepeat.value,
|
||||
timezone: guess,
|
||||
});
|
||||
setRecurrenceType(recurrenceOptions.doesNotRepeat.value);
|
||||
setDurationUnit('m');
|
||||
}
|
||||
}, [open, defaultName, form]);
|
||||
|
||||
const handleFinish = async (values: MuteSchedulerFormData): Promise<void> => {
|
||||
const isRecurring =
|
||||
values.repeatType &&
|
||||
values.repeatType !== recurrenceOptions.doesNotRepeat.value;
|
||||
|
||||
const payload: MutePayload = {
|
||||
name: values.name.trim(),
|
||||
startTime: values.startTime?.format() || dayjs().format(),
|
||||
endTime: values.endTime ? values.endTime.format() : null,
|
||||
timezone: values.timezone,
|
||||
recurrence: isRecurring
|
||||
? {
|
||||
duration: values.duration ? `${values.duration}${durationUnit}` : '',
|
||||
repeatOn: values.repeatOn as any,
|
||||
repeatType: values.repeatType as any,
|
||||
startTime: values.startTime?.format() || dayjs().format(),
|
||||
endTime: values.endTime ? values.endTime.format() : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const requiredRule = [{ required: true }];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
closable={false}
|
||||
destroyOnClose
|
||||
className="mute-scheduler-drawer"
|
||||
rootClassName="mute-scheduler-drawer-root"
|
||||
>
|
||||
<div className="mute-scheduler-drawer__header">
|
||||
<div className="mute-scheduler-drawer__title">
|
||||
<BellOff size={18} color="var(--bg-amber-500)" />
|
||||
<span>Mute this alert rule</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mute-scheduler-drawer__close"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="mute-scheduler-drawer__subtitle">
|
||||
Creates a planned silence for <strong>{ruleName || 'this rule'}</strong> —
|
||||
rule continues to evaluate; notifications are suppressed for the window
|
||||
below.
|
||||
</p>
|
||||
<div className="mute-scheduler-drawer__divider" />
|
||||
|
||||
<Form<MuteSchedulerFormData>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
onValuesChange={(_, all): void => {
|
||||
if (all.repeatType !== recurrenceType) {
|
||||
setRecurrenceType(all.repeatType);
|
||||
}
|
||||
}}
|
||||
className="mute-scheduler-drawer__form"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item label="Name" name="name" rules={requiredRule}>
|
||||
<Input placeholder="e.g. Deployment window" maxLength={120} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Starts" name="startTime" rules={requiredRule}>
|
||||
<DatePicker
|
||||
className="mute-scheduler-drawer__date"
|
||||
showTime
|
||||
showNow={false}
|
||||
format={(date): string => date.format(DATE_FORMAT)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Ends"
|
||||
name="endTime"
|
||||
required={recurrenceType === recurrenceOptions.doesNotRepeat.value}
|
||||
rules={[
|
||||
{
|
||||
required: recurrenceType === recurrenceOptions.doesNotRepeat.value,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DatePicker
|
||||
className="mute-scheduler-drawer__date"
|
||||
showTime
|
||||
showNow={false}
|
||||
format={(date): string => date.format(DATE_FORMAT)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mute-scheduler-drawer__row">
|
||||
<Form.Item label="Repeats every" name="repeatType" rules={requiredRule}>
|
||||
<Select placeholder="Select" options={recurrenceOptionWithSubmenu} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Timezone" name="timezone" rules={requiredRule}>
|
||||
<Select placeholder="Select timezone" showSearch options={TZ_OPTIONS} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{recurrenceType === recurrenceOptions.weekly.value && (
|
||||
<Form.Item label="Weekly occurrence" name="repeatOn" rules={requiredRule}>
|
||||
<Select
|
||||
placeholder="Select days"
|
||||
mode="multiple"
|
||||
options={Object.values(recurrenceWeeklyOptions)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{recurrenceType &&
|
||||
recurrenceType !== recurrenceOptions.doesNotRepeat.value && (
|
||||
<Form.Item label="Duration" name="duration" rules={requiredRule}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Enter duration"
|
||||
addonAfter={
|
||||
<Select
|
||||
value={durationUnit}
|
||||
onChange={(v): void => setDurationUnit(v)}
|
||||
options={DURATION_UNIT_OPTIONS}
|
||||
/>
|
||||
}
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div className="mute-scheduler-drawer__callout">
|
||||
<Info size={14} color="var(--bg-aqua-500)" />
|
||||
<p>
|
||||
The rule will <strong>keep evaluating</strong> and firing alerts to the
|
||||
History tab. Only notifications (Slack, PagerDuty, email) are silenced.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mute-scheduler-drawer__footer">
|
||||
<Button type="text" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
icon={<Check size={14} />}
|
||||
>
|
||||
Mute alert
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default MuteSchedulerDrawer;
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BellOff } from '@signozhq/icons';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { ActiveMute } from './useActiveMute';
|
||||
|
||||
import './StateBanners.styles.scss';
|
||||
|
||||
const PLANNED_DOWNTIMES_URL = `${ROUTES.LIST_ALL_ALERT}?tab=Configuration&subTab=planned-downtime`;
|
||||
|
||||
const formatRemaining = (endTime: string | undefined): string | null => {
|
||||
if (!endTime) {
|
||||
return null;
|
||||
}
|
||||
const end = dayjs(endTime);
|
||||
const now = dayjs();
|
||||
const diffMs = end.diff(now);
|
||||
if (diffMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60000);
|
||||
const days = Math.floor(totalMinutes / (60 * 24));
|
||||
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h LEFT`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m LEFT`;
|
||||
}
|
||||
return `${minutes}m LEFT`;
|
||||
};
|
||||
|
||||
const isIndefinite = (endTime: string | undefined): boolean => {
|
||||
if (!endTime) {
|
||||
return true;
|
||||
}
|
||||
// If end is more than 5 years away, treat as indefinite (matches "Forever" sentinel).
|
||||
return dayjs(endTime).diff(dayjs(), 'year') >= 5;
|
||||
};
|
||||
|
||||
interface MutedBannerProps {
|
||||
activeMute: ActiveMute;
|
||||
}
|
||||
|
||||
function MutedBanner({ activeMute }: MutedBannerProps): JSX.Element {
|
||||
const endTime = activeMute.effectiveEndTime ?? undefined;
|
||||
const indefinite = isIndefinite(endTime);
|
||||
const [remaining, setRemaining] = useState<string | null>(
|
||||
indefinite ? null : formatRemaining(endTime),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (indefinite) {
|
||||
return undefined;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(formatRemaining(endTime));
|
||||
}, 60_000);
|
||||
return (): void => clearInterval(interval);
|
||||
}, [endTime, indefinite]);
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
if (indefinite) {
|
||||
return 'Notifications muted indefinitely';
|
||||
}
|
||||
if (!endTime) {
|
||||
return 'Notifications muted';
|
||||
}
|
||||
return `Notifications muted until ${dayjs(endTime).format('MMM D, h:mm A')}`;
|
||||
}, [endTime, indefinite]);
|
||||
|
||||
const reason = activeMute.description || activeMute.name;
|
||||
|
||||
return (
|
||||
<div className="state-banner state-banner--muted" role="status">
|
||||
<div className="state-banner__icon-disc state-banner__icon-disc--muted">
|
||||
<BellOff size={18} color="var(--bg-amber-500)" />
|
||||
</div>
|
||||
<div className="state-banner__body">
|
||||
<div className="state-banner__title">
|
||||
<span>{titleText}</span>
|
||||
{!indefinite && remaining && (
|
||||
<span className="state-banner__pill state-banner__pill--muted">
|
||||
{remaining}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="state-banner__meta">
|
||||
<span>
|
||||
Rule is still evaluating — fires will appear in <strong>History</strong>.
|
||||
</span>
|
||||
{reason && (
|
||||
<>
|
||||
{' · '}
|
||||
<span>
|
||||
Name: <strong>{reason}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{' · '}
|
||||
<Link to={PLANNED_DOWNTIMES_URL} className="state-banner__link">
|
||||
Manage in Planned Downtimes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MutedBanner;
|
||||
@@ -1,98 +0,0 @@
|
||||
.state-banner {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
|
||||
&--muted {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 205, 86, 0.1),
|
||||
rgba(255, 205, 86, 0.04)
|
||||
);
|
||||
border: 1px solid rgba(255, 205, 86, 0.25);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: rgba(98, 104, 124, 0.06);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
}
|
||||
|
||||
&__icon-disc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--muted {
|
||||
background: rgba(255, 205, 86, 0.15);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: rgba(98, 104, 124, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
|
||||
&--muted {
|
||||
color: var(--bg-amber-500);
|
||||
background: rgba(255, 205, 86, 0.12);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
color: var(--bg-slate-50);
|
||||
background: rgba(98, 104, 124, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
strong {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--bg-robin-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-400);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetRuleByID } from 'api/generated/services/rules';
|
||||
import type { RuletypesActiveMuteInfoDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type ActiveMute = RuletypesActiveMuteInfoDTO;
|
||||
|
||||
type UseActiveMuteResult = {
|
||||
activeMute: ActiveMute | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
};
|
||||
|
||||
export const useActiveMute = (
|
||||
ruleId: string | undefined,
|
||||
): UseActiveMuteResult => {
|
||||
const { data, isLoading, isFetching, refetch } = useGetRuleByID(
|
||||
{ id: ruleId || '' },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(ruleId),
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const activeMute = useMemo(() => data?.data?.activeMute ?? undefined, [data]);
|
||||
|
||||
return {
|
||||
activeMute,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch: () => {
|
||||
void refetch();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import {
|
||||
createDowntimeSchedule,
|
||||
getListDowntimeSchedulesQueryKey,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import {
|
||||
getGetRuleByIDQueryKey,
|
||||
getListRulesQueryKey,
|
||||
} from 'api/generated/services/rules';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type {
|
||||
AlertmanagertypesPostablePlannedMaintenanceDTO,
|
||||
AlertmanagertypesRecurrenceDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export type MutePayload = {
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime?: string | null;
|
||||
timezone: string;
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO;
|
||||
};
|
||||
|
||||
type UseMuteAlertRuleArgs = {
|
||||
ruleId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
type UseMuteAlertRuleResult = {
|
||||
mute: (payload: MutePayload) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const useMuteAlertRule = ({
|
||||
ruleId,
|
||||
onSuccess,
|
||||
}: UseMuteAlertRuleArgs): UseMuteAlertRuleResult => {
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync, isLoading } = useMutation(
|
||||
['createMuteDowntime', ruleId],
|
||||
(payload: AlertmanagertypesPostablePlannedMaintenanceDTO) =>
|
||||
createDowntimeSchedule(payload),
|
||||
{
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries(getListDowntimeSchedulesQueryKey());
|
||||
void queryClient.invalidateQueries(getGetRuleByIDQueryKey({ id: ruleId }));
|
||||
void queryClient.invalidateQueries(getListRulesQueryKey());
|
||||
notifications.success({ message: 'Alert muted' });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mute = useCallback(
|
||||
async (payload: MutePayload): Promise<void> => {
|
||||
if (!ruleId) {
|
||||
return;
|
||||
}
|
||||
const body: AlertmanagertypesPostablePlannedMaintenanceDTO = {
|
||||
name: payload.name,
|
||||
alertIds: [ruleId],
|
||||
schedule: {
|
||||
startTime: payload.startTime,
|
||||
// null = no end ("Forever"). The generated type narrows endTime to
|
||||
// string, but the API accepts null to mean indefinite.
|
||||
endTime:
|
||||
payload.endTime === null ? (null as unknown as string) : payload.endTime,
|
||||
timezone: payload.timezone,
|
||||
recurrence: payload.recurrence,
|
||||
},
|
||||
};
|
||||
await mutateAsync(body);
|
||||
},
|
||||
[mutateAsync, ruleId],
|
||||
);
|
||||
|
||||
return { mute, isLoading };
|
||||
};
|
||||
@@ -29,5 +29,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetWaterfallV4",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace (OOM-safe)",
|
||||
Description: "Two-step fetch: minimal fields for all spans to build the tree, full fields only for the visible window. Aggregations are not included in the response.",
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -79,6 +80,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseDashboardV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether dashboard v2 is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -38,3 +38,24 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantype
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
|
||||
}
|
||||
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
@@ -61,6 +61,86 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
return traceData, nil
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan()
|
||||
}
|
||||
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveLimit := min(req.Limit, m.config.Waterfall.MaxLimitToSelectAllSpans)
|
||||
if summary.NumSpans > uint64(effectiveLimit) {
|
||||
return m.getWindowedWaterfall(ctx, traceID, req, summary, effectiveLimit)
|
||||
}
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan()
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
selectedSpans := waterfallTrace.GetAllSpans()
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall, summary *spantypes.TraceSummary, effectiveLimit uint) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(minimalSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(minimalSpans))
|
||||
for i := range minimalSpans {
|
||||
nodes[i] = minimalSpans[i].ToWaterfallSpan()
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
|
||||
selectedSpans, uncollapsedSpans := waterfallTrace.GetSelectedSpans(
|
||||
req.UncollapsedSpans,
|
||||
req.SelectedSpanID,
|
||||
m.config.Waterfall.SpanPageSize,
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
// Step 2: full fetch for the selected window only
|
||||
spanIDs := make([]string, len(selectedSpans))
|
||||
for i, s := range selectedSpans {
|
||||
spanIDs[i] = s.SpanID
|
||||
}
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, summary, spanIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(
|
||||
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
// The $$$$ becomes $$ since go-sqlbuilder escapes $ sign.
|
||||
const serviceNameCol = "resource_string_service$$$$name"
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
@@ -69,3 +72,64 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
}
|
||||
return spanItems, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.MinimalSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) span_id",
|
||||
"parent_span_id", "timestamp", "duration_nano", "has_error",
|
||||
serviceNameCol,
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.MinimalSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying minimal spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, summary *spantypes.TraceSummary, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
if len(spanIDs) == 0 {
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "trace_id", "has_error", "kind",
|
||||
serviceNameCol, "name", "links as references",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
}
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.In("span_id", ids...),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans by IDs")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
}
|
||||
|
||||
@@ -1770,6 +1770,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -29,23 +29,15 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := handler.ruler.ListRuleStates(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedules, _ := handler.ruler.MaintenanceStore().ListPlannedMaintenance(ctx, claims.OrgID)
|
||||
|
||||
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
|
||||
for _, rule := range rules.Rules {
|
||||
view = append(view, ruletypes.NewRule(rule, schedules))
|
||||
view = append(view, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
@@ -55,12 +47,6 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
@@ -73,9 +59,7 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
schedules, _ := handler.ruler.MaintenanceStore().ListPlannedMaintenance(ctx, claims.OrgID)
|
||||
|
||||
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, schedules))
|
||||
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -95,7 +79,7 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule, nil))
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -166,7 +150,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, nil))
|
||||
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -642,114 +642,23 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveMuteInfo holds the currently active mute window for an alert rule.
|
||||
type ActiveMuteInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EffectiveStartTime *time.Time `json:"effectiveStartTime,omitempty"`
|
||||
EffectiveEndTime *time.Time `json:"effectiveEndTime,omitempty"`
|
||||
}
|
||||
|
||||
// findActiveMuteForRule returns the active mute window for a rule, if any.
|
||||
// Scope expressions are intentionally skipped here because we operate at the
|
||||
// rule level (no alert labels available), matching the frontend's behaviour.
|
||||
func findActiveMuteForRule(ruleID string, schedules []*alertmanagertypes.PlannedMaintenance) *ActiveMuteInfo {
|
||||
if len(schedules) == 0 || ruleID == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
type candidate struct {
|
||||
m *alertmanagertypes.PlannedMaintenance
|
||||
end *time.Time
|
||||
}
|
||||
|
||||
var candidates []candidate
|
||||
for _, m := range schedules {
|
||||
if m.Schedule == nil {
|
||||
continue
|
||||
}
|
||||
// Empty RuleIDs means the window applies to all rules.
|
||||
if len(m.RuleIDs) > 0 {
|
||||
found := false
|
||||
for _, id := range m.RuleIDs {
|
||||
if id == ruleID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !m.IsActive(now) {
|
||||
continue
|
||||
}
|
||||
var end *time.Time
|
||||
if m.Schedule.Recurrence != nil {
|
||||
end = m.Schedule.Recurrence.EndTime
|
||||
} else if !m.Schedule.EndTime.IsZero() {
|
||||
t := m.Schedule.EndTime
|
||||
end = &t
|
||||
}
|
||||
candidates = append(candidates, candidate{m: m, end: end})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by soonest end so the most specific window wins; nil (forever) sorts last.
|
||||
slices.SortFunc(candidates, func(a, b candidate) int {
|
||||
if a.end == nil && b.end == nil {
|
||||
return 0
|
||||
}
|
||||
if a.end == nil {
|
||||
return 1
|
||||
}
|
||||
if b.end == nil {
|
||||
return -1
|
||||
}
|
||||
return a.end.Compare(*b.end)
|
||||
})
|
||||
|
||||
w := candidates[0]
|
||||
info := &ActiveMuteInfo{
|
||||
ID: w.m.ID.StringValue(),
|
||||
Name: w.m.Name,
|
||||
Description: w.m.Description,
|
||||
}
|
||||
if w.m.Schedule.Recurrence != nil {
|
||||
t := w.m.Schedule.Recurrence.StartTime
|
||||
info.EffectiveStartTime = &t
|
||||
} else if !w.m.Schedule.StartTime.IsZero() {
|
||||
t := w.m.Schedule.StartTime
|
||||
info.EffectiveStartTime = &t
|
||||
}
|
||||
info.EffectiveEndTime = w.end
|
||||
return info
|
||||
}
|
||||
|
||||
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
|
||||
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
|
||||
// PlannedMaintenance and other entities. v1 handlers keep serializing
|
||||
// GettableRule directly for back-compat with existing SDK / Terraform clients.
|
||||
type Rule struct {
|
||||
Id string `json:"id" required:"true"`
|
||||
State AlertState `json:"state" required:"true"`
|
||||
ActiveMute *ActiveMuteInfo `json:"activeMute,omitempty"`
|
||||
Id string `json:"id" required:"true"`
|
||||
State AlertState `json:"state" required:"true"`
|
||||
PostableRule
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
}
|
||||
|
||||
func NewRule(g *GettableRule, schedules []*alertmanagertypes.PlannedMaintenance) *Rule {
|
||||
func NewRule(g *GettableRule) *Rule {
|
||||
r := &Rule{
|
||||
Id: g.Id,
|
||||
State: g.State,
|
||||
PostableRule: g.PostableRule,
|
||||
ActiveMute: findActiveMuteForRule(g.Id, schedules),
|
||||
}
|
||||
r.CreatedAt = g.CreatedAt
|
||||
r.UpdatedAt = g.UpdatedAt
|
||||
|
||||
@@ -26,4 +26,6 @@ type SpanMapperStore interface {
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, summary *TraceSummary, spanIDs []string) ([]StorableSpan, error)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,31 @@ type StorableSpan struct {
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
type MinimalSpan struct {
|
||||
SpanID string `ch:"span_id"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
HasError bool `ch:"has_error"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
}
|
||||
|
||||
func (item *MinimalSpan) ToWaterfallSpan() *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
SpanID: item.SpanID,
|
||||
ParentSpanID: item.ParentSpanID,
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
ServiceName: item.ServiceName,
|
||||
Resource: map[string]string{"service.name": item.ServiceName},
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
Attributes: make(map[string]any),
|
||||
Events: make([]Event, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
|
||||
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
@@ -297,6 +322,24 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
|
||||
}
|
||||
}
|
||||
|
||||
func EnrichSelectedSpans(window []*WaterfallSpan, fullSpans []StorableSpan) {
|
||||
fullByID := make(map[string]*StorableSpan, len(fullSpans))
|
||||
for i := range fullSpans {
|
||||
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
|
||||
}
|
||||
for i, ws := range window {
|
||||
full, ok := fullByID[ws.SpanID]
|
||||
if !ok {
|
||||
continue // synthesized MissingSpan — keep empty shell
|
||||
}
|
||||
newWS := full.ToWaterfallSpan()
|
||||
newWS.Level = ws.Level
|
||||
newWS.HasChildren = ws.HasChildren
|
||||
newWS.SubTreeNodeCount = ws.SubTreeNodeCount
|
||||
window[i] = newWS
|
||||
}
|
||||
}
|
||||
|
||||
// getSpanIndex returns the index of matched span and -1 for no match.
|
||||
func getSpanIndex(spans []*WaterfallSpan, targetSpanID string) int {
|
||||
for i, s := range spans {
|
||||
|
||||
@@ -62,26 +62,24 @@ func NewWaterfallTrace(
|
||||
}
|
||||
}
|
||||
|
||||
func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
|
||||
// NewWaterfallTraceFromSpans requires WaterfallSpan nodes with only below fields:
|
||||
// SpanID, ParentSpanID, TimeUnix, DurationNano, HasError, and ServiceName.
|
||||
func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
|
||||
var (
|
||||
startTime, endTime, totalErrorSpans uint64
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(spans))
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(nodes))
|
||||
traceRoots []*WaterfallSpan
|
||||
hasMissingSpans bool
|
||||
)
|
||||
|
||||
for _, item := range spans {
|
||||
span := item.ToWaterfallSpan()
|
||||
startTimeUnixNano := uint64(item.StartTime.UnixNano())
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
for _, span := range nodes {
|
||||
if startTime == 0 || span.TimeUnix < startTime {
|
||||
startTime = span.TimeUnix
|
||||
}
|
||||
endTime = max(endTime, startTimeUnixNano+span.DurationNano)
|
||||
|
||||
endTime = max(endTime, span.TimeUnix+span.DurationNano)
|
||||
if span.HasError {
|
||||
totalErrorSpans++
|
||||
}
|
||||
|
||||
spanIDToSpanNodeMap[span.SpanID] = span
|
||||
}
|
||||
|
||||
@@ -116,7 +114,7 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
|
||||
return NewWaterfallTrace(
|
||||
startTime,
|
||||
endTime,
|
||||
uint64(len(spans)),
|
||||
uint64(len(nodes)),
|
||||
totalErrorSpans,
|
||||
spanIDToSpanNodeMap,
|
||||
traceRoots,
|
||||
|
||||
Reference in New Issue
Block a user