mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-26 20:00:33 +01:00
Compare commits
20 Commits
metricsExp
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02a515c68 | ||
|
|
ffa33f71e1 | ||
|
|
51820ee87c | ||
|
|
36f29ccced | ||
|
|
c1e81a57a3 | ||
|
|
c30994f966 | ||
|
|
9aef69a186 | ||
|
|
66bc4b4398 | ||
|
|
04c58a1572 | ||
|
|
9bbdd00858 | ||
|
|
316e9c7361 | ||
|
|
634166860b | ||
|
|
af8f2fa95a | ||
|
|
f81fd78ff6 | ||
|
|
b589a7b2e9 | ||
|
|
716dbc7847 | ||
|
|
3a92c7577f | ||
|
|
ba043a5741 | ||
|
|
6d2b99eb8d | ||
|
|
3765ca3d42 |
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz-community
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
@@ -182,11 +182,6 @@ alertmanager:
|
||||
poll_interval: 1m
|
||||
# The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself.
|
||||
external_url: http://localhost:8080
|
||||
# The list of globs from which SigNoz's alertmanager notification templates are loaded (e.g. the email.signoz.html layout).
|
||||
# This mirrors the upstream alertmanager `templates` config option. The upstream default templates (default.tmpl, email.tmpl)
|
||||
# are always loaded from the embedded alertmanager assets, so only SigNoz's own templates need to be listed here.
|
||||
templates:
|
||||
- /opt/signoz/conf/templates/alertmanager/*.gotmpl
|
||||
# The global configuration for the alertmanager. All the exahustive fields can be found in the upstream: https://github.com/prometheus/alertmanager/blob/efa05feffd644ba4accb526e98a8c6545d26a783/config/config.go#L833
|
||||
global:
|
||||
# ResolveTimeout is the time after which an alert is declared resolved if it has not been updated.
|
||||
|
||||
@@ -129,8 +129,6 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
|
||||
updatedAt:
|
||||
@@ -274,8 +272,6 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- schedule
|
||||
@@ -18948,77 +18944,6 @@ paths:
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3
|
||||
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
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -535,7 +535,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, provider cloudintegrationtypes.CloudProviderType, service *cloudintegrationtypes.CloudIntegrationService, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
// TODO: DB calls are in for loop, can be optimized later.
|
||||
for _, dashboard := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, service.Type, dashboard.ID)
|
||||
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, service.Type, dashboard.ID)
|
||||
|
||||
existing, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
@@ -562,7 +562,7 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
|
||||
// deprovisionDashboards deletes all dashboard and integration_dashboard rows for the given service.
|
||||
// make sure to call this within a transaction.
|
||||
func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID) error {
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
slugPrefix := cloudintegrationtypes.IntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
rows, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -588,7 +588,7 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -23,6 +24,7 @@ type APIHandlerOptions struct {
|
||||
DataConnector interfaces.Reader
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
|
||||
GatewayUrl string
|
||||
// Querier Influx Interval
|
||||
@@ -40,6 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
@@ -88,6 +91,17 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
|
||||
|
||||
router.HandleFunc(
|
||||
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
|
||||
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionResponse := basemodel.GetVersionResponse{
|
||||
Version: version.Info.Version(),
|
||||
|
||||
@@ -89,15 +89,6 @@ 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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
@@ -85,13 +86,20 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
// initiate opamp
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore, signoz.Modules.Dashboard)
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create cloud provider integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// ingestion pipelines manager
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
signoz.SQLStore,
|
||||
@@ -126,6 +134,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
DataConnector: reader,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
@@ -191,6 +200,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
|
||||
@@ -94,19 +94,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="signoz-boot-settings">
|
||||
[[.Settings]]
|
||||
</script>
|
||||
<script>
|
||||
try {
|
||||
var _el = document.getElementById('signoz-boot-settings');
|
||||
window.signozBootData = {
|
||||
settings: _el ? JSON.parse(_el.textContent) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
window.signozBootData = { settings: null };
|
||||
}
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -148,10 +135,7 @@
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
if (APPCUES_APP_ID) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
|
||||
@@ -17,6 +17,8 @@ const BANNED_COMPONENTS = {
|
||||
Typography:
|
||||
'Use @signozhq/ui/typography Typography instead of antd Typography.',
|
||||
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
|
||||
Dropdown:
|
||||
'Use @signozhq/ui DropdownMenuSimple (or the composable DropdownMenu primitives) from @signozhq/ui/dropdown-menu instead of antd Dropdown.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { bootSettings } from 'utils/bootData';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -333,7 +332,7 @@ function App(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
|
||||
if (process.env.POSTHOG_KEY) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
|
||||
@@ -225,10 +225,6 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -1718,10 +1714,6 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostableRoutePolicyDTO {
|
||||
@@ -9232,17 +9224,6 @@ 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,8 +14,6 @@ import type {
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -122,102 +120,3 @@ export const useGetWaterfall = <
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.dropdown-button {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
|
||||
import './DropDown.styles.scss';
|
||||
|
||||
function DropDown({
|
||||
element,
|
||||
onDropDownItemClick,
|
||||
}: {
|
||||
element: JSX.Element[];
|
||||
onDropDownItemClick?: MenuProps['onClick'];
|
||||
}): JSX.Element {
|
||||
const items: MenuProps['items'] = element.map(
|
||||
(e: JSX.Element, index: number) => ({
|
||||
label: e,
|
||||
key: index,
|
||||
}),
|
||||
);
|
||||
|
||||
const [isDdOpen, setDdOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items,
|
||||
onMouseEnter: (): void => setDdOpen(true),
|
||||
onMouseLeave: (): void => setDdOpen(false),
|
||||
onClick: (item): void => onDropDownItemClick?.(item),
|
||||
}}
|
||||
open={isDdOpen}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
className={`dropdown-button`}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
setDdOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="dropdown-icon" size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
DropDown.defaultProps = {
|
||||
onDropDownItemClick: (): void => {},
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Popover,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { Button, Col, Popover, Row, Select, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -241,9 +233,9 @@ function ExplorerCard({
|
||||
</Popover>
|
||||
<Share2 onClick={onCopyUrlHandler} size="md" />
|
||||
{viewKey && (
|
||||
<Dropdown trigger={['click']} menu={moreOptionMenu}>
|
||||
<Ellipsis size="md" />
|
||||
</Dropdown>
|
||||
<DropdownMenuSimple menu={moreOptionMenu}>
|
||||
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Space>
|
||||
</OffSetCol>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -195,7 +195,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
)}
|
||||
|
||||
{isMultiQueryAllowed && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
className="query-actions-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
@@ -217,10 +217,10 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
align="end"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,14 @@ import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps } from 'antd';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { SlidersHorizontal } from '@signozhq/icons';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ResizeTable from './ResizeTable';
|
||||
import { DynamicColumnTableProps } from './types';
|
||||
@@ -84,8 +84,9 @@ function DynamicColumnTable({
|
||||
);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] =
|
||||
const items: MenuItem[] =
|
||||
dynamicColumns?.map((column, index) => ({
|
||||
key: String(index),
|
||||
label: (
|
||||
<div
|
||||
className="dynamicColumnsTable-items"
|
||||
@@ -99,8 +100,6 @@ function DynamicColumnTable({
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
key: index,
|
||||
type: 'checkbox',
|
||||
})) || [];
|
||||
|
||||
// Get current page from URL or default to 1
|
||||
@@ -129,18 +128,14 @@ function DynamicColumnTable({
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
|
||||
{dynamicColumns && (
|
||||
<Dropdown
|
||||
getPopupContainer={popupContainer}
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
className="dynamicColumnTable-button filter-btn"
|
||||
size="middle"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
data-testid="additional-filters-button"
|
||||
/>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, Globe } from '@signozhq/icons';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TimeItems, {
|
||||
timePreferance,
|
||||
@@ -27,20 +28,17 @@ function TimePreference({
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: menuItems,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
items: menuItems.map((item) => ({
|
||||
...item,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
})),
|
||||
}),
|
||||
[timeMenuItemOnChangeHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
rootClassName="time-selection-menu"
|
||||
className="time-selection-target"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button>
|
||||
<DropdownMenuSimple menu={menu} className="time-selection-menu">
|
||||
<Button className="time-selection-target">
|
||||
<div className="button-selected-text">
|
||||
<Globe size={14} />
|
||||
<Typography.Text className="selected-value">
|
||||
@@ -49,7 +47,7 @@ function TimePreference({
|
||||
</div>
|
||||
<ChevronDown size="md" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION;
|
||||
|
||||
export const INVITE_MEMBERS_HASH = '#invite-team-members';
|
||||
|
||||
export const SIGNOZ_UPGRADE_PLAN_URL =
|
||||
'https://upgrade.signoz.io/upgrade-from-app';
|
||||
|
||||
export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval';
|
||||
|
||||
export const DEFAULT_ENTITY_VERSION = 'v3';
|
||||
|
||||
@@ -11,5 +11,4 @@ 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',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
@@ -312,6 +313,16 @@ describe('Create Alert Channel (Normal User)', () => {
|
||||
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('Should check if the upgrade plan message is shown', () => {
|
||||
expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This feature is available for paid plans only./),
|
||||
).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Click here' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL);
|
||||
expect(screen.getByText(/to Upgrade/)).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'button_save_channel' }),
|
||||
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
ZeustypesHostDTO,
|
||||
@@ -200,10 +205,15 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={isFetchingHosts}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="link" color="none" disabled={isFetchingHosts}>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
@@ -236,14 +246,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button variant="link" color="none">
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -142,12 +143,13 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
fireEvent.click(
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
MetricRangePayloadV3,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { prepareBarPanelConfig } from '../utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
getLegend: jest.fn(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getLabelName', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(
|
||||
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({
|
||||
getInitialStackedBands: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
const getInitialStackedBandsMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
).getInitialStackedBands as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result,
|
||||
resultType: 'matrix',
|
||||
newResult: null as unknown as MetricRangePayloadV3,
|
||||
},
|
||||
});
|
||||
|
||||
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
thresholds: [],
|
||||
customLegendColors: {},
|
||||
...overrides,
|
||||
}) as Widgets;
|
||||
|
||||
const defaultTimezone = {
|
||||
name: 'UTC',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
};
|
||||
|
||||
describe('BarPanel utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getLabelNameMock.mockReturnValue('baseLabel');
|
||||
getLegendMock.mockImplementation(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('prepareBarPanelData', () => {
|
||||
it('returns aligned data with timestamps and empty series when result is empty', () => {
|
||||
const data = prepareChartData(createApiResponse([]));
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns timestamps and one series of y values for single series', () => {
|
||||
const data = prepareChartData(
|
||||
createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
legend: 'Series A',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]),
|
||||
);
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data[0]).toStrictEqual([1000, 2000]);
|
||||
expect(data[1]).toStrictEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('merges timestamps and fills missing values with null for multiple series', () => {
|
||||
const data = prepareChartData(
|
||||
createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[3000, '3'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]),
|
||||
);
|
||||
expect(data[0]).toStrictEqual([1000, 2000, 3000]);
|
||||
expect(data[1]).toStrictEqual([1, null, 3]);
|
||||
expect(data[2]).toStrictEqual([10, 20, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareBarPanelConfig', () => {
|
||||
const baseParams = {
|
||||
widget: createWidget(),
|
||||
isDarkMode: true,
|
||||
currentQuery: {} as Query,
|
||||
onClick: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
apiResponse: createApiResponse(),
|
||||
timezone: defaultTimezone,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
};
|
||||
|
||||
it('adds no series when apiResponse has empty result', () => {
|
||||
const config = prepareBarPanelConfig(baseParams).getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result item', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
legend: 'L1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: {} as Query,
|
||||
}).getConfig();
|
||||
expect(getLegendMock).toHaveBeenCalled();
|
||||
expect(config.series?.[1]).toMatchObject({ label: 'legend-baseLabel' });
|
||||
});
|
||||
|
||||
it('uses getLabelName for label when currentQuery is null', () => {
|
||||
getLegendMock.mockReset();
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: { __name__: 'requests' },
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: null as unknown as Query,
|
||||
});
|
||||
expect(getLabelNameMock).toHaveBeenCalled();
|
||||
expect(getLegendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes result metric to each series for cross-panel sync', () => {
|
||||
const metric = { host: 'server1', __name__: 'http_requests' };
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric,
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ metric });
|
||||
});
|
||||
|
||||
it('uses widget customLegendColors for series stroke', () => {
|
||||
const widget = createWidget({
|
||||
customLegendColors: { 'legend-baseLabel': '#ff0000' },
|
||||
});
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
widget,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
|
||||
});
|
||||
|
||||
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
|
||||
const widget = createWidget({ stackedBarChart: true });
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
|
||||
// seriesCount = result.length + 1 = 3
|
||||
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('does not call getInitialStackedBands for non-stacked chart', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, apiResponse });
|
||||
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -303,27 +303,6 @@ describe('TimeSeriesPanel utils', () => {
|
||||
expect(seriesConfig!.stroke).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('passes result metric to each series for cross-panel sync', () => {
|
||||
const metric = { host: 'server1', __name__: 'cpu' };
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric,
|
||||
queryName: 'Q',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[2000, '2'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const config = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series?.[1]).toMatchObject({ metric });
|
||||
});
|
||||
|
||||
it('adds multiple series when result has multiple items', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { CloudDownload } from '@signozhq/icons';
|
||||
import { Button, Dropdown, MenuProps, Flex } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { DownloadProps } from './Download.types';
|
||||
@@ -67,7 +68,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<DropdownMenuSimple menu={menu}>
|
||||
<Button
|
||||
className="download-button"
|
||||
loading={isLoading || isDownloading}
|
||||
@@ -79,7 +80,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
Download
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
Col,
|
||||
Dropdown as DropDownComponent,
|
||||
Input as InputComponent,
|
||||
} from 'antd';
|
||||
import { Col, Input as InputComponent } from 'antd';
|
||||
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -34,16 +30,6 @@ export const ButtonContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const Dropdown = styled(DropDownComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 150px;
|
||||
min-width: 150px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextContainer = styled.div`
|
||||
&&& {
|
||||
min-width: 100px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
@@ -176,6 +177,7 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
|
||||
describe('WidgetGraphComponent', () => {
|
||||
it('should show correct menu items when hovering over more options while loading', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { getByTestId, findByRole, getByText, container } = render(
|
||||
<MockQueryClientProvider>
|
||||
<ErrorModalProvider>
|
||||
@@ -208,7 +210,7 @@ describe('WidgetGraphComponent', () => {
|
||||
expect(skeleton).toBeInTheDocument();
|
||||
|
||||
const moreOptionsButton = getByTestId('widget-header-options');
|
||||
fireEvent.mouseEnter(moreOptionsButton);
|
||||
await user.click(moreOptionsButton);
|
||||
|
||||
const menu = await findByRole('menu');
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
@@ -54,6 +54,17 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
// currently the width of the dropdown menu is set to 100% of the parent container,
|
||||
// which is not desired. This is a workaround to unset that width and allow the dropdown menu to size based on its content.
|
||||
// This is necessary because the dropdown menu can contain items with varying widths, and setting it to 100% can cause layout issues and make the menu look unbalanced.
|
||||
// we should idealy fix this in the dropdown menu component itself, but for now this is a quick fix to ensure the dropdown menu looks correct in the widget header.
|
||||
|
||||
[data-radix-popper-content-wrapper]
|
||||
[data-slot='dropdown-menu-content'].widget-header-dropdown
|
||||
[data-slot='dropdown-menu-item'] {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.widget-api-actions {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('WidgetHeader', () => {
|
||||
|
||||
describe('Create Alerts Menu Item', () => {
|
||||
it('renders Create Alerts menu item with external link icon when included in headerMenuList', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
@@ -483,7 +484,7 @@ describe('WidgetHeader', () => {
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
expect(moreOptionsIcon).toBeInTheDocument();
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await user.click(moreOptionsIcon);
|
||||
|
||||
await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
@@ -494,6 +495,7 @@ describe('WidgetHeader', () => {
|
||||
});
|
||||
|
||||
it('Create Alerts menu item is enabled and clickable', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockCreateAlertsHandler = jest.fn();
|
||||
const useCreateAlerts = jest.requireMock(
|
||||
'hooks/queryBuilder/useCreateAlerts',
|
||||
@@ -517,12 +519,12 @@ describe('WidgetHeader', () => {
|
||||
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await user.click(moreOptionsIcon);
|
||||
|
||||
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
// Verify the menu item is clickable by actually clicking it
|
||||
await userEvent.click(createAlertsMenuItem);
|
||||
await user.click(createAlertsMenuItem);
|
||||
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Dropdown, Input, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
|
||||
@@ -128,7 +129,7 @@ function WidgetHeader({
|
||||
],
|
||||
);
|
||||
|
||||
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
|
||||
const onMenuItemSelectHandler = useCallback(
|
||||
({ key }: { key: string }): void => {
|
||||
if (isTWidgetOptions(key)) {
|
||||
const functionToCall = keyMethodMapping[key];
|
||||
@@ -188,18 +189,8 @@ function WidgetHeader({
|
||||
{
|
||||
key: MenuItemKeys.CreateAlerts,
|
||||
icon: <Bell size="md" />,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
|
||||
<SquareArrowOutUpRight size={10} />
|
||||
</span>
|
||||
),
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts],
|
||||
rightIcon: <SquareArrowOutUpRight size="lg" />,
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
|
||||
disabled: false,
|
||||
},
|
||||
@@ -221,8 +212,10 @@ function WidgetHeader({
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: updatedMenuList,
|
||||
onClick: onMenuItemSelectHandler,
|
||||
items: updatedMenuList.map((item) => ({
|
||||
...item,
|
||||
onClick: onMenuItemSelectHandler,
|
||||
})),
|
||||
}),
|
||||
[updatedMenuList, onMenuItemSelectHandler],
|
||||
);
|
||||
@@ -321,7 +314,12 @@ function WidgetHeader({
|
||||
/>
|
||||
)}
|
||||
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<DropdownMenuSimple
|
||||
menu={menu}
|
||||
side="bottom"
|
||||
align="end"
|
||||
className="widget-header-dropdown"
|
||||
>
|
||||
<Button
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
@@ -329,7 +327,7 @@ function WidgetHeader({
|
||||
}`}
|
||||
icon={<EllipsisVertical size="md" />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface MenuItem {
|
||||
key: MenuItemKeys;
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
isVisible: boolean;
|
||||
disabled: boolean;
|
||||
danger?: boolean;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
|
||||
import type { MenuItem as DropdownMenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { MenuItemKeys } from './contants';
|
||||
import { MenuItem } from './types';
|
||||
|
||||
export const generateMenuList = (actions: MenuItem[]): MenuItemType[] =>
|
||||
export const generateMenuList = (actions: MenuItem[]): DropdownMenuItem[] =>
|
||||
actions
|
||||
.filter((action: MenuItem) => action.isVisible)
|
||||
.map(({ key, icon: Icon, label, disabled, ...rest }) => ({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Button, Flex, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Ellipsis, Plus } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -15,7 +16,6 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import DropDown from 'components/DropDown/DropDown';
|
||||
import {
|
||||
DynamicColumnsKey,
|
||||
TableDataSource,
|
||||
@@ -323,55 +323,67 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
dataIndex: 'id',
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
|
||||
<div data-testid="alert-actions">
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void =>
|
||||
alertActionLogEvent(item.key, record)
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => {
|
||||
const actionItems = [
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-new-tab"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-clone"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
];
|
||||
return (
|
||||
<div data-testid="alert-actions">
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: actionItems.map((element, index) => ({
|
||||
key: String(index),
|
||||
label: element,
|
||||
onClick: ({ key }): void => alertActionLogEvent(key, record),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
style={{ color: 'var(--l1-foreground)' }}
|
||||
icon={<Ellipsis size={16} />}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
MenuProps,
|
||||
Modal,
|
||||
Popover,
|
||||
Skeleton,
|
||||
@@ -553,7 +552,7 @@ function DashboardsList(): JSX.Element {
|
||||
];
|
||||
|
||||
const getCreateDashboardItems = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<div
|
||||
@@ -711,11 +710,11 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
{createNewDashboard && (
|
||||
<section className="actions">
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
<DropdownMenuSimple
|
||||
className="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -727,7 +726,7 @@ function DashboardsList(): JSX.Element {
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
<Button
|
||||
type="text"
|
||||
className="learn-more"
|
||||
@@ -756,11 +755,11 @@ function DashboardsList(): JSX.Element {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{createNewDashboard && (
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
<DropdownMenuSimple
|
||||
className="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -773,7 +772,7 @@ function DashboardsList(): JSX.Element {
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import { Dropdown, MenuProps } from 'antd';
|
||||
import {
|
||||
type BaseMenuItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
@@ -135,41 +141,38 @@ function BodyTitleRenderer({
|
||||
viewName,
|
||||
]);
|
||||
|
||||
const onClickHandler: MenuProps['onClick'] = (props): void => {
|
||||
const onClickHandler = (key: string): void => {
|
||||
const mapper = {
|
||||
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
|
||||
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
|
||||
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
|
||||
};
|
||||
|
||||
const handler = mapper[props.key];
|
||||
const handler = mapper[key];
|
||||
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const menu: MenuProps = {
|
||||
items: [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
onClick: onClickHandler,
|
||||
};
|
||||
const menuItems: BaseMenuItem[] = [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
@@ -218,15 +221,23 @@ function BodyTitleRenderer({
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
dropdownRender={(originNode): React.ReactNode => (
|
||||
<div data-log-detail-ignore="true">{originNode}</div>
|
||||
)}
|
||||
>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div data-log-detail-ignore="true">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
onSelect={(): void => onClickHandler(item.key as string)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
@@ -95,7 +94,7 @@ function MembersSettings(): JSX.Element {
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
const filterMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
@@ -171,10 +170,9 @@ function MembersSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="members-settings__controls">
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="members-filter-dropdown"
|
||||
className="members-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -184,7 +182,7 @@ function MembersSettings(): JSX.Element {
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="members-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
@@ -76,14 +77,15 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
fireEvent.click(pendingOption);
|
||||
await user.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
useGetMetricAlerts,
|
||||
@@ -126,12 +127,11 @@ function DashboardsAndAlertsPopover({
|
||||
return (
|
||||
<div className="dashboards-and-alerts-popover-container">
|
||||
{dashboardsPopoverContent && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: dashboardsPopoverContent,
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className="dashboards-and-alerts-popover dashboards-popover"
|
||||
@@ -142,15 +142,14 @@ function DashboardsAndAlertsPopover({
|
||||
{pluralize(dashboards.length, 'dashboard')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
{alertsPopoverContent && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: alertsPopoverContent,
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className="dashboards-and-alerts-popover alerts-popover"
|
||||
@@ -161,7 +160,7 @@ function DashboardsAndAlertsPopover({
|
||||
{pluralize(alerts.length, 'alert rule')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -104,38 +104,33 @@
|
||||
height: 200px;
|
||||
background-color: var(--l1-border);
|
||||
overflow: hidden !important;
|
||||
.ant-dropdown-menu {
|
||||
padding: 0;
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
padding: 4px;
|
||||
.ant-checkbox-wrapper {
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
padding: 4px;
|
||||
.ant-checkbox-wrapper {
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
|
||||
.attribute-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 160px;
|
||||
overflow: scroll;
|
||||
}
|
||||
.attribute-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 160px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar {
|
||||
width: 3px; /* Width of the scrollbar */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar {
|
||||
width: 3px; /* Width of the scrollbar */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-track {
|
||||
background: var(--l1-border); /* Color of the track */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-track {
|
||||
background: var(--l1-border); /* Color of the track */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-thumb {
|
||||
background: var(--l2-foreground); /* Color of the thumb */
|
||||
border-radius: 4px; /* Roundness of the thumb */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-thumb {
|
||||
background: var(--l2-foreground); /* Color of the thumb */
|
||||
border-radius: 4px; /* Roundness of the thumb */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border); /* Color of the thumb on hover */
|
||||
}
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border); /* Color of the thumb on hover */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Dropdown, Input, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Divider, Input, Tooltip } from 'antd';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { FieldDataType } from 'api/v5/v5';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -159,34 +164,12 @@ function ExplorerColumnsRenderer({
|
||||
debouncedSetQuerySearchText(e.target.value);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="explorer-columns-search"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={16} style={{ padding: '6px' }} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'columns',
|
||||
label: (
|
||||
<ExplorerAttributeColumns
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedLogField = (name: string): void => {
|
||||
if (
|
||||
@@ -238,13 +221,6 @@ function ExplorerColumnsRenderer({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = (): void => {
|
||||
setOpen(!open);
|
||||
if (!open) {
|
||||
setSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
@@ -327,25 +303,38 @@ function ExplorerColumnsRenderer({
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
arrow
|
||||
placement="top"
|
||||
open={open}
|
||||
overlayClassName="explorer-columns-dropdown"
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
data-testid="add-columns-button"
|
||||
icon={
|
||||
<CirclePlus
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
|
||||
/>
|
||||
}
|
||||
onClick={toggleDropdown}
|
||||
/>
|
||||
</Dropdown>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="action-btn"
|
||||
data-testid="add-columns-button"
|
||||
icon={
|
||||
<CirclePlus
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="explorer-columns-dropdown">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="explorer-columns-search"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={16} style={{ padding: '6px' }} />}
|
||||
/>
|
||||
<ExplorerAttributeColumns
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,6 +146,7 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
});
|
||||
|
||||
it('opens and closes the dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Wrapper>
|
||||
<ExplorerColumnsRenderer
|
||||
@@ -158,12 +159,12 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
);
|
||||
|
||||
const addButton = screen.getByTestId('add-columns-button');
|
||||
await userEvent.click(addButton);
|
||||
await user.click(addButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(addButton);
|
||||
await user.click(addButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { ContextLinkProps, Widgets } from 'types/api/dashboard/getAll';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import VariablesDropdown from './VariablesDropdown';
|
||||
import VariablesPopover from './VariablesPopover';
|
||||
|
||||
import './UpdateContextLinks.styles.scss';
|
||||
|
||||
@@ -71,7 +71,7 @@ function UpdateContextLinks({
|
||||
customVariables: fieldVariables,
|
||||
});
|
||||
|
||||
// Transform variables into the format expected by VariablesDropdown
|
||||
// Transform variables into the format expected by VariablesPopover
|
||||
const transformedVariables = useMemo(
|
||||
() => transformContextVariables(variables),
|
||||
[variables],
|
||||
@@ -224,7 +224,9 @@ function UpdateContextLinks({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<VariablesDropdown
|
||||
{/* TODO: replace with AutoComplete with options for variables and
|
||||
previously used URLs for better UX */}
|
||||
<VariablesPopover
|
||||
onVariableSelect={handleVariableSelect}
|
||||
variables={transformedVariables}
|
||||
>
|
||||
@@ -252,7 +254,7 @@ function UpdateContextLinks({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</VariablesDropdown>
|
||||
</VariablesPopover>
|
||||
</Form.Item>
|
||||
|
||||
{/* Remove the separate variables section */}
|
||||
@@ -282,7 +284,7 @@ function UpdateContextLinks({
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<VariablesDropdown
|
||||
<VariablesPopover
|
||||
onVariableSelect={(variableName, cursorPosition): void =>
|
||||
handleParamVariableSelect(index, variableName, cursorPosition)
|
||||
}
|
||||
@@ -311,7 +313,7 @@ function UpdateContextLinks({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</VariablesDropdown>
|
||||
</VariablesPopover>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
.variables-dropdown-container {
|
||||
.url-input-trigger {
|
||||
width: 100%;
|
||||
|
||||
.url-input-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Ant Design dropdown styles
|
||||
.ant-dropdown-menu {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.variable-source {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import './VariablesDropdown.styles.scss';
|
||||
|
||||
interface VariablesDropdownProps {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
variables: VariableItem[];
|
||||
children: (props: {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
cursorPosition: number | null;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
interface VariableItem {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function VariablesDropdown({
|
||||
onVariableSelect,
|
||||
variables,
|
||||
children,
|
||||
}: VariablesDropdownProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click outside handler
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return (): void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
key: v.name,
|
||||
label: (
|
||||
<div className="variable-row">
|
||||
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className="variable-source">{v.source}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
[variables],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variables-dropdown-container" ref={wrapperRef}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: dropdownItems,
|
||||
onClick: ({ key }): void => {
|
||||
const variableName = key as string;
|
||||
onVariableSelect(`{{${variableName}}}`, cursorPosition || undefined);
|
||||
setIsOpen(false);
|
||||
},
|
||||
}}
|
||||
open={isOpen}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
getPopupContainer={(): HTMLElement => wrapperRef.current || document.body}
|
||||
>
|
||||
{children({
|
||||
onVariableSelect,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
})}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesDropdown;
|
||||
@@ -0,0 +1,74 @@
|
||||
.variables-popover-container {
|
||||
.url-input-trigger {
|
||||
width: 100%;
|
||||
|
||||
.url-input-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-popover-anchor-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-popover-content {
|
||||
// antd Modal uses z-index ~1000; popover must sit above it.
|
||||
z-index: 1100;
|
||||
padding: 4px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: var(--radix-popover-trigger-width);
|
||||
}
|
||||
|
||||
.variables-popover-empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--l3-foreground, #999);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.variables-popover-item {
|
||||
all: unset;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--l1-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.variable-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.variable-name,
|
||||
.variable-source {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.variable-source {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Uses Popover (not DropdownMenu like the rest of the antd-dropdown migration):
|
||||
// DropdownMenuTrigger preventDefaults pointerdown, breaking input focus and
|
||||
// dismissing on every keystroke. PopoverAnchor is a passive positioning element.
|
||||
import { ReactNode, useRef, useState } from 'react';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import './VariablesPopover.styles.scss';
|
||||
|
||||
interface VariablesPopoverProps {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
variables: VariableItem[];
|
||||
children: (props: {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
cursorPosition: number | null;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
interface VariableItem {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function VariablesPopover({
|
||||
onVariableSelect,
|
||||
variables,
|
||||
children,
|
||||
}: VariablesPopoverProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean): void => {
|
||||
// Accept "close" events from the popover (outside-click, Esc) but ignore
|
||||
// opens — opening is driven by the input's onFocus in the consumer.
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="variables-popover-container">
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="variables-popover-anchor-wrap" ref={anchorRef}>
|
||||
{children({
|
||||
onVariableSelect,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
})}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="variables-popover-content"
|
||||
onOpenAutoFocus={(e): void => e.preventDefault()}
|
||||
onCloseAutoFocus={(e): void => e.preventDefault()}
|
||||
onInteractOutside={(e): void => {
|
||||
// Keep the popover open while interacting with the anchor (the input),
|
||||
// otherwise typing/clicking the input would close it immediately.
|
||||
const target = e.target as Node | null;
|
||||
if (target && anchorRef.current?.contains(target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onFocusOutside={(e): void => {
|
||||
const target = e.target as Node | null;
|
||||
if (target && anchorRef.current?.contains(target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<div className="variables-popover-empty">No variables available</div>
|
||||
) : (
|
||||
variables.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
className="variables-popover-item"
|
||||
onMouseDown={(e): void => {
|
||||
// Prevent the input from losing focus when clicking an item.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={(): void => {
|
||||
onVariableSelect(`{{${v.name}}}`, cursorPosition || undefined);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="variable-row">
|
||||
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className="variable-source">
|
||||
{v.source}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesPopover;
|
||||
@@ -204,7 +204,7 @@ const processContextLinks = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms context variables into the format expected by VariablesDropdown
|
||||
* Transforms context variables into the format expected by VariablesPopover
|
||||
* @param variables - Array of context variables from useContextVariables
|
||||
* @returns Array of transformed variables with proper source descriptions
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button, ColorPicker, Dropdown, MenuProps, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, ColorPicker, Space } from 'antd';
|
||||
import type { Color } from 'antd/es/color-picker';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
@@ -26,7 +27,7 @@ function ColorSelector({
|
||||
setColorFromPicker(hex);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'Red',
|
||||
label: <CustomColor color="Red" />,
|
||||
@@ -62,7 +63,7 @@ function ColorSelector({
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
onClick={(e): void => e.preventDefault()}
|
||||
className="color-selector-button"
|
||||
@@ -72,7 +73,7 @@ function ColorSelector({
|
||||
<ChevronDown size="md" />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,20 +81,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-all-warning {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formItemWithBullet {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Info } from '@signozhq/icons';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
FormInstance,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
SelectProps,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
@@ -72,18 +70,14 @@ const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
|
||||
}),
|
||||
);
|
||||
|
||||
type AlertRuleScope = 'all' | 'specific';
|
||||
|
||||
interface PlannedDowntimeFormData {
|
||||
name: string;
|
||||
startTime: dayjs.Dayjs | null;
|
||||
endTime: dayjs.Dayjs | null;
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO;
|
||||
alertRuleScope: AlertRuleScope;
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
@@ -133,12 +127,6 @@ export function PlannedDowntimeForm(
|
||||
recurrenceOptions.doesNotRepeat.value,
|
||||
);
|
||||
|
||||
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
|
||||
initialValues.id && (initialValues.alertIds || []).length === 0
|
||||
? 'all'
|
||||
: 'specific',
|
||||
);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -152,14 +140,10 @@ export function PlannedDowntimeForm(
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds:
|
||||
values.alertRuleScope === 'all'
|
||||
? []
|
||||
: (values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[]),
|
||||
alertIds: values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[],
|
||||
name: values.name,
|
||||
scope: values.scope,
|
||||
schedule: {
|
||||
startTime: values.startTime?.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
@@ -278,13 +262,12 @@ export function PlannedDowntimeForm(
|
||||
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
|
||||
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
|
||||
|
||||
const initialAlertIds = initialValues.alertIds || [];
|
||||
|
||||
return {
|
||||
name: defaultTo(initialValues.name, ''),
|
||||
alertRuleScope:
|
||||
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
|
||||
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
|
||||
alertRules: getAlertOptionsFromIds(
|
||||
initialValues.alertIds || [],
|
||||
alertOptions,
|
||||
),
|
||||
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
|
||||
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
|
||||
recurrence: {
|
||||
@@ -295,13 +278,11 @@ export function PlannedDowntimeForm(
|
||||
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
|
||||
} as AlertmanagertypesRecurrenceDTO,
|
||||
timezone: schedule?.timezone as string,
|
||||
scope: initialValues.scope || '',
|
||||
};
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formattedInitialValues.alertRules);
|
||||
setAlertRuleScope(formattedInitialValues.alertRuleScope);
|
||||
form.setFieldsValue({ ...formattedInitialValues });
|
||||
}, [form, formattedInitialValues, initialValues]);
|
||||
|
||||
@@ -330,7 +311,7 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
const endTime = formData.endTime;
|
||||
@@ -341,7 +322,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -364,7 +345,6 @@ export function PlannedDowntimeForm(
|
||||
onFinish={onFinish}
|
||||
onValuesChange={(): void => {
|
||||
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
|
||||
setAlertRuleScope(form.getFieldValue('alertRuleScope') as AlertRuleScope);
|
||||
handleFormData(form.getFieldsValue());
|
||||
}}
|
||||
autoComplete="off"
|
||||
@@ -464,107 +444,50 @@ export function PlannedDowntimeForm(
|
||||
<div className="scheduleTimeInfoText">{endTimeText}</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Form.Item
|
||||
name="alertRuleScope"
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="all">All alert rules</Radio>
|
||||
<Radio value="specific">Specific alert rules</Radio>
|
||||
</Radio.Group>
|
||||
<div className="alert-rule-form">
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
|
||||
(Leave empty to silence all alerts)
|
||||
</Typography>
|
||||
</div>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
{alertRuleScope === 'specific' && (
|
||||
<>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={alertRuleFormName}
|
||||
rules={[
|
||||
{
|
||||
validator: async (
|
||||
_rule,
|
||||
value: DefaultOptionType[] | undefined,
|
||||
): Promise<void> => {
|
||||
if (!value || value.length === 0) {
|
||||
throw new Error(
|
||||
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Search for alert rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleAlertRulesChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)
|
||||
?.toLowerCase()
|
||||
?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Scope
|
||||
<Tooltip
|
||||
mouseLeaveDelay={0.3}
|
||||
title={
|
||||
<Form.Item name={alertRuleFormName}>
|
||||
<Select
|
||||
placeholder="Search for alerts rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleAlertRulesChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
Scope the planned downtime by alert labels.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Info size={13} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="scope"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder='e.g. env = "prod" AND region = "us-east-1"'
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<ModalButtonWrapper>
|
||||
<Button
|
||||
|
||||
@@ -204,7 +204,7 @@ export function CollapseListContent({
|
||||
selectedTags={alertOptions}
|
||||
/>
|
||||
) : (
|
||||
<Tag className="all-alerts-tag">All alert rules</Tag>
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from '../drilldownUtils';
|
||||
import { AggregateData } from '../useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate, {
|
||||
buildDrilldownUrl,
|
||||
getRoute,
|
||||
} from '../useBaseDrilldownNavigate';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../drilldownUtils', () => ({
|
||||
...jest.requireActual('../drilldownUtils'),
|
||||
getViewQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetViewQuery = getViewQuery as jest.Mock;
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_QUERY: Query = {
|
||||
id: 'q1',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const MOCK_VIEW_QUERY: Query = {
|
||||
...MOCK_QUERY,
|
||||
builder: {
|
||||
...MOCK_QUERY.builder,
|
||||
queryData: [
|
||||
{
|
||||
...MOCK_QUERY.builder.queryData[0],
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_AGGREGATE_DATA: AggregateData = {
|
||||
queryName: 'A',
|
||||
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
|
||||
timeRange: { startTime: 1000000, endTime: 2000000 },
|
||||
};
|
||||
|
||||
// ─── getRoute ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRoute', () => {
|
||||
it.each([
|
||||
['view_logs', ROUTES.LOGS_EXPLORER],
|
||||
['view_metrics', ROUTES.METRICS_EXPLORER],
|
||||
['view_traces', ROUTES.TRACES_EXPLORER],
|
||||
])('maps %s to the correct explorer route', (key, expected) => {
|
||||
expect(getRoute(key)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns empty string for an unknown key', () => {
|
||||
expect(getRoute('view_dashboard')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDrilldownUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for an unknown drilldown key', () => {
|
||||
const url = buildDrilldownUrl(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA,
|
||||
'view_dashboard',
|
||||
);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a URL starting with the logs explorer route for view_logs', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
});
|
||||
|
||||
it('returns a URL starting with the traces explorer route for view_traces', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
|
||||
expect(url).toContain(ROUTES.TRACES_EXPLORER);
|
||||
});
|
||||
|
||||
it('includes compositeQuery param in the URL', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('includes startTime and endTime when aggregateData has a timeRange', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('startTime=1000000');
|
||||
expect(url).toContain('endTime=2000000');
|
||||
});
|
||||
|
||||
it('omits startTime and endTime when aggregateData has no timeRange', () => {
|
||||
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
|
||||
expect(url).not.toContain('startTime=');
|
||||
expect(url).not.toContain('endTime=');
|
||||
});
|
||||
|
||||
it('includes summaryFilters param for view_metrics', () => {
|
||||
const url = buildDrilldownUrl(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA,
|
||||
'view_metrics',
|
||||
);
|
||||
expect(url).toContain(ROUTES.METRICS_EXPLORER);
|
||||
expect(url).toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('does not include summaryFilters param for non-metrics routes', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('handles null aggregateData by passing empty filters and empty queryName', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(
|
||||
MOCK_QUERY,
|
||||
[],
|
||||
'view_logs',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes aggregateData filters and queryName to getViewQuery', () => {
|
||||
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA.filters,
|
||||
'view_logs',
|
||||
MOCK_AGGREGATE_DATA.queryName,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
|
||||
|
||||
describe('useBaseDrilldownNavigate', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls safeNavigate with the built URL on a valid key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('opens the explorer in a new tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_traces');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(expect.any(String), {
|
||||
newTab: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls callback after successful navigation', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call safeNavigate for an unknown key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when the key is unknown', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null aggregateData without throwing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => result.current('view_logs')).not.toThrow();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -168,7 +168,7 @@ export const getAggregateColumnHeader = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
Object.keys(metric).map((key) => ({
|
||||
filterKey: key,
|
||||
filterValue: metric[key],
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Link, Loader } from '@signozhq/icons';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
@@ -14,10 +18,9 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ContextMenuItem } from './contextConfig';
|
||||
import { getDataLinks } from './dataLinksUtils';
|
||||
import { getAggregateColumnHeader } from './drilldownUtils';
|
||||
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
|
||||
import { getBaseContextConfig } from './menuOptions';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
|
||||
|
||||
interface UseBaseAggregateOptionsProps {
|
||||
query: Query;
|
||||
@@ -35,6 +38,19 @@ interface BaseAggregateOptionsConfig {
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const useBaseAggregateOptions = ({
|
||||
query,
|
||||
onClose,
|
||||
@@ -70,6 +86,8 @@ const useBaseAggregateOptions = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, aggregateData, panelType]);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Use the new useContextVariables hook
|
||||
const { processedVariables } = useContextVariables({
|
||||
maxValues: 2,
|
||||
@@ -103,16 +121,50 @@ const useBaseAggregateOptions = ({
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
|
||||
|
||||
const handleBaseDrilldown = useBaseDrilldownNavigate({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback: onClose,
|
||||
});
|
||||
const handleBaseDrilldown = useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
filtersToAdd,
|
||||
key,
|
||||
aggregateData?.queryName || '',
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
}),
|
||||
} as Record<string, string>;
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery?.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (route) {
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, onClose, aggregateData],
|
||||
);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from './drilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
|
||||
|
||||
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
|
||||
view_logs: ROUTES.LOGS_EXPLORER,
|
||||
view_metrics: ROUTES.METRICS_EXPLORER,
|
||||
view_traces: ROUTES.TRACES_EXPLORER,
|
||||
};
|
||||
|
||||
const getRoute = (key: string): string =>
|
||||
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
|
||||
|
||||
interface UseBaseDrilldownNavigateProps {
|
||||
resolvedQuery: Query;
|
||||
aggregateData: AggregateData | null;
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
const useBaseDrilldownNavigate = ({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback,
|
||||
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
callback?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
|
||||
callback?.();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, callback, aggregateData],
|
||||
);
|
||||
};
|
||||
|
||||
export function buildDrilldownUrl(
|
||||
resolvedQuery: Query,
|
||||
aggregateData: AggregateData | null,
|
||||
key: string,
|
||||
): string | null {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return `${route}?${createQueryParams(queryParams)}`;
|
||||
}
|
||||
|
||||
export { getRoute };
|
||||
export default useBaseDrilldownNavigate;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
@@ -134,7 +133,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
const filterMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
@@ -231,10 +230,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
) : (
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
className="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -247,7 +245,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -129,6 +130,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -137,10 +139,10 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
fireEvent.click(activeOption);
|
||||
await user.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
|
||||
@@ -662,7 +662,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.pinned):hover,
|
||||
&:not(.pinned).is-hovered,
|
||||
&.dropdown-open {
|
||||
flex: 0 0 240px;
|
||||
max-width: 240px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -25,7 +26,14 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Dropdown, MenuProps, Modal, Tooltip } from 'antd';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, MenuProps, Modal, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Logout } from 'api/utils';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
@@ -162,7 +170,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
const [hasScroll, setHasScroll] = useState(false);
|
||||
const navTopSectionRef = useRef<HTMLDivElement>(null);
|
||||
const sidenavRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const isDropdownOpenRef = useRef(false);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
|
||||
@@ -175,9 +185,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// When the dropdown is open its content renders in a portal outside
|
||||
// the sidenav, which causes the browser to fire mouseleave on the
|
||||
// sidenav. Keep the sidenav expanded in that case.
|
||||
if (isDropdownOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const handleDropdownOpenChange = useCallback((open: boolean): void => {
|
||||
isDropdownOpenRef.current = open;
|
||||
setIsDropdownOpen(open);
|
||||
if (!open) {
|
||||
// Re-sync hover state on close: the cursor may have moved to the
|
||||
// portal content (outside .sideNav), so mouseleave never fired.
|
||||
requestAnimationFrame(() => {
|
||||
setIsHovered(sidenavRef.current?.matches(':hover') ?? false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkScroll = useCallback((): void => {
|
||||
if (navTopSectionRef.current) {
|
||||
const { scrollHeight, clientHeight, scrollTop } = navTopSectionRef.current;
|
||||
@@ -959,9 +987,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className={cx('sidenav-container', isPinned && 'pinned')}>
|
||||
<div
|
||||
ref={sidenavRef}
|
||||
className={cx(
|
||||
'sideNav',
|
||||
isPinned && 'pinned',
|
||||
isHovered && 'is-hovered',
|
||||
isDropdownOpen && 'dropdown-open',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
@@ -1182,46 +1212,95 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
{isAIAssistantEnabled && renderNavItems([aiAssistantMenuItem], false)}
|
||||
|
||||
<div className="nav-dropdown-item">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: helpSupportDropdownMenuItems,
|
||||
onClick: handleHelpSupportMenuItemClick,
|
||||
}}
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay help-support-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className="nav-item">
|
||||
<div className="nav-item-data" data-testid="help-support-nav-item">
|
||||
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
|
||||
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="nav-item">
|
||||
<div className="nav-item-data" data-testid="help-support-nav-item">
|
||||
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
|
||||
|
||||
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
|
||||
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="nav-dropdown-overlay help-support-dropdown"
|
||||
>
|
||||
{helpSupportDropdownMenuItems.map((item, idx) => {
|
||||
if ('type' in item) {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DropdownMenuSeparator key={`help-sep-${idx}`} />;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={String(item.key)}
|
||||
leftIcon={item.icon}
|
||||
onClick={(e): void =>
|
||||
handleHelpSupportMenuItemClick({
|
||||
...item,
|
||||
key: String(item.key),
|
||||
domEvent: e.nativeEvent,
|
||||
} as unknown as SidebarItem)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="nav-dropdown-item">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userSettingsDropdownMenuItems,
|
||||
onClick: handleSettingsMenuItemClick,
|
||||
}}
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay settings-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className={cx('nav-item', isSettingsPage && 'active')}>
|
||||
<div className="nav-item-active-marker" />
|
||||
<div className="nav-item-data" data-testid="settings-nav-item">
|
||||
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
|
||||
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cx('nav-item', isSettingsPage && 'active')}>
|
||||
<div className="nav-item-active-marker" />
|
||||
<div className="nav-item-data" data-testid="settings-nav-item">
|
||||
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
|
||||
|
||||
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
|
||||
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="nav-dropdown-overlay settings-dropdown"
|
||||
>
|
||||
{(userSettingsDropdownMenuItems ?? []).map((item, idx) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
if ('type' in item && item.type === 'divider') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DropdownMenuSeparator key={`settings-sep-${idx}`} />;
|
||||
}
|
||||
const settingsItem = item as {
|
||||
key?: string | number;
|
||||
label?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={String(settingsItem.key)}
|
||||
leftIcon={settingsItem.icon}
|
||||
disabled={settingsItem.disabled}
|
||||
onClick={(e): void =>
|
||||
handleSettingsMenuItemClick({
|
||||
key: String(settingsItem.key),
|
||||
domEvent: e.nativeEvent,
|
||||
} as unknown as SidebarItem)
|
||||
}
|
||||
>
|
||||
{settingsItem.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { syncCursorRegistry } from '../syncCursorRegistry';
|
||||
|
||||
describe('syncCursorRegistry', () => {
|
||||
describe('metadata', () => {
|
||||
it('returns undefined for unknown key', () => {
|
||||
expect(syncCursorRegistry.getMetadata('unknown-meta')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores and retrieves metadata by syncKey', () => {
|
||||
const metadata = { yAxisUnit: 'ms', groupBy: [] };
|
||||
syncCursorRegistry.setMetadata('meta-key', metadata);
|
||||
expect(syncCursorRegistry.getMetadata('meta-key')).toBe(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeSeriesMetric', () => {
|
||||
it('returns null (not undefined) for unknown key', () => {
|
||||
expect(
|
||||
syncCursorRegistry.getActiveSeriesMetric('unknown-metric'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves metric by syncKey', () => {
|
||||
const metric = { host: 'server1', __name__: 'cpu' };
|
||||
syncCursorRegistry.setActiveSeriesMetric('metric-key', metric);
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric('metric-key')).toBe(metric);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,627 +0,0 @@
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from '../syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../syncDisplayHook';
|
||||
import {
|
||||
SyncTooltipFilterMode,
|
||||
type TooltipControllerState,
|
||||
type TooltipSyncMetadata,
|
||||
} from '../types';
|
||||
|
||||
jest.mock('../syncCursorRegistry', () => ({
|
||||
syncCursorRegistry: {
|
||||
setMetadata: jest.fn(),
|
||||
getMetadata: jest.fn(),
|
||||
setActiveSeriesMetric: jest.fn(),
|
||||
getActiveSeriesMetric: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockRegistry = syncCursorRegistry as {
|
||||
setMetadata: jest.Mock;
|
||||
getMetadata: jest.Mock;
|
||||
setActiveSeriesMetric: jest.Mock;
|
||||
getActiveSeriesMetric: jest.Mock;
|
||||
};
|
||||
|
||||
const SYNC_KEY = 'test-sync-key';
|
||||
|
||||
// Builds a single-query groupByPerQuery from a list of dimension keys.
|
||||
const makeGroupByPerQuery = (
|
||||
...keys: string[]
|
||||
): Record<string, BaseAutocompleteData[]> => ({
|
||||
A: keys.map((key) => ({ key, type: 'tag' as const })),
|
||||
});
|
||||
|
||||
function makeUPlotRoot(includeCrosshair = true): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
if (includeCrosshair) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'u-cursor-y';
|
||||
root.append(el);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
type FakeSeries = { metric?: Record<string, string>; show?: boolean };
|
||||
|
||||
function makeFakeUPlot(opts: {
|
||||
cursorEvent?: MouseEvent | null;
|
||||
cursorLeft?: number;
|
||||
series?: FakeSeries[];
|
||||
includeCrosshair?: boolean;
|
||||
}): uPlot {
|
||||
return {
|
||||
root: makeUPlotRoot(opts.includeCrosshair ?? true),
|
||||
cursor: {
|
||||
event: opts.cursorEvent !== undefined ? opts.cursorEvent : null,
|
||||
left: opts.cursorLeft ?? 50,
|
||||
},
|
||||
series: opts.series ?? [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
],
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeController(
|
||||
focusedSeriesIndex: number | null = null,
|
||||
): TooltipControllerState {
|
||||
return {
|
||||
focusedSeriesIndex,
|
||||
syncedSeriesIndexes: null,
|
||||
} as TooltipControllerState;
|
||||
}
|
||||
|
||||
// Convenience cast used throughout assertions.
|
||||
function mockSetSeries(u: uPlot): jest.Mock {
|
||||
return (u as unknown as { setSeries: jest.Mock }).setSeries;
|
||||
}
|
||||
|
||||
function getCrosshair(u: uPlot): HTMLElement {
|
||||
const el = u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!el) {
|
||||
throw new Error('crosshair element missing');
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createSyncDisplayHook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── guard ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no crosshair element', () => {
|
||||
it('returns early without calling registry when .u-cursor-y absent', () => {
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
|
||||
const u = makeFakeUPlot({ includeCrosshair: false });
|
||||
hook(u);
|
||||
expect(mockRegistry.setMetadata).not.toHaveBeenCalled();
|
||||
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
|
||||
expect(mockSetSeries(u)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── source panel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('source behavior (cursor.event != null)', () => {
|
||||
it('writes syncMetadata to registry', () => {
|
||||
const syncMetadata: TooltipSyncMetadata = { yAxisUnit: 'ms' };
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, syncMetadata, makeController());
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(mockRegistry.setMetadata).toHaveBeenCalledWith(
|
||||
SYNC_KEY,
|
||||
syncMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes focused series metric when focusedSeriesIndex is set', () => {
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController(1));
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: new MouseEvent('mousemove'),
|
||||
series,
|
||||
});
|
||||
hook(u);
|
||||
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(SYNC_KEY, {
|
||||
host: 'server1',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes null metric when focusedSeriesIndex is null', () => {
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
undefined,
|
||||
makeController(null),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(
|
||||
SYNC_KEY,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears controller.syncedSeriesIndexes', () => {
|
||||
const controller = makeController();
|
||||
controller.syncedSeriesIndexes = [1, 2];
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, controller);
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
|
||||
it('shows crosshair and does not read from registry', () => {
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('');
|
||||
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── receiver panel ───────────────────────────────────────────────────────
|
||||
|
||||
describe('receiver behavior (cursor.event is null)', () => {
|
||||
describe('crosshair visibility', () => {
|
||||
it('shows crosshair when yAxisUnit matches source', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('');
|
||||
});
|
||||
|
||||
it('hides crosshair when yAxisUnit differs from source', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'bytes' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
// ── exact groupBy match ───────────────────────────────────────────────
|
||||
|
||||
describe('exact groupBy match', () => {
|
||||
const groupByPerQuery = makeGroupByPerQuery('host');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
|
||||
it('focuses the matching series and records it on the controller', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server2' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
|
||||
});
|
||||
|
||||
it('unfocuses all and emits empty matches (Filtered) when active metric is null', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('unfocuses all when metric matches no series', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'unknown-server',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('clears syncedSeriesIndexes when cursor is off-plot (left < 0)', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: -1, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(mockRegistry.getActiveSeriesMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never focuses series at index 0 (x-axis)', () => {
|
||||
const sameMetric = { host: 'server1' };
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(sameMetric);
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
// Index 0 has the same metric — it must always be skipped.
|
||||
series: [{ metric: sameMetric }, { metric: { host: 'other' } }],
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('skips hidden series (show === false)', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
series: [
|
||||
{},
|
||||
{ metric: { host: 'server1' }, show: false },
|
||||
{ metric: { host: 'server1' } },
|
||||
],
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── partial groupBy overlap ───────────────────────────────────────────
|
||||
|
||||
describe('partial groupBy overlap', () => {
|
||||
it('subset — records every receiver series matching on the common key', () => {
|
||||
// Source groupBy=[host], receiver groupBy=[host, service].
|
||||
// Hook focuses the first match; the rest are surfaced via controller.syncedSeriesIndexes.
|
||||
const sourceGroupBy = makeGroupByPerQuery('host');
|
||||
const receiverGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1', service: 'api' } },
|
||||
{ metric: { host: 'server1', service: 'frontend' } },
|
||||
{ metric: { host: 'server2', service: 'api' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('superset — records the one receiver series matching on the common key', () => {
|
||||
// Source groupBy=[host, service], receiver groupBy=[host].
|
||||
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const receiverGroupBy = makeGroupByPerQuery('host');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'server1',
|
||||
service: 'api',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
|
||||
});
|
||||
|
||||
it('partial — matches on the intersecting key only', () => {
|
||||
// Source groupBy=[host, service], receiver groupBy=[service, region].
|
||||
// Common key is [service]. Both receiver series with service=api match.
|
||||
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const receiverGroupBy = makeGroupByPerQuery('service', 'region');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { service: 'api', region: 'us-east' } },
|
||||
{ metric: { service: 'api', region: 'eu-west' } },
|
||||
{ metric: { service: 'frontend', region: 'us-east' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'server1',
|
||||
service: 'api',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── union across queries in groupByPerQuery ───────────────────────────
|
||||
|
||||
describe('union across queries', () => {
|
||||
it("treats the panel's effective groupBy as the union across its queries", () => {
|
||||
// Source has query A=[host]; receiver has A=[host], B=[service].
|
||||
// The shared key is `host` — receiver matches on that.
|
||||
const sourceGroupBy: Record<string, BaseAutocompleteData[]> = {
|
||||
A: [{ key: 'host', type: 'tag' }],
|
||||
};
|
||||
const receiverGroupBy: Record<string, BaseAutocompleteData[]> = {
|
||||
A: [{ key: 'host', type: 'tag' }],
|
||||
B: [{ key: 'service', type: 'tag' }],
|
||||
};
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
series: [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
],
|
||||
});
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── no overlap (Filtered mode default) ────────────────────────────────
|
||||
|
||||
describe('no overlap → Filtered mode emits []', () => {
|
||||
it('emits [] when groupBy keys are completely different', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('service') },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits [] when receiver groupBy is empty', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: {} },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits [] when source groupBy is absent', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('host') },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── filterMode: All ──────────────────────────────────────────────────
|
||||
|
||||
describe('filterMode All', () => {
|
||||
it('emits null (no filter) when there is no overlap in groupBy', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('service'),
|
||||
filterMode: SyncTooltipFilterMode.All,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
|
||||
it('emits null when metric matches no series', () => {
|
||||
const groupByPerQuery = makeGroupByPerQuery('host');
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'unknown' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
filterMode: SyncTooltipFilterMode.All,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50 });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── caching ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('caching optimizations', () => {
|
||||
it('reuses the crosshair element across multiple invocations', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
const spy = jest.spyOn(u.root, 'querySelector');
|
||||
hook(u);
|
||||
hook(u);
|
||||
hook(u);
|
||||
// querySelector should only be called once regardless of invocation count.
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recomputes common keys when source groupByPerQuery reference changes', () => {
|
||||
const hostGroupBy = makeGroupByPerQuery('host');
|
||||
const serviceGroupBy = makeGroupByPerQuery('service');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1', service: 'api' } },
|
||||
{ metric: { host: 'server2', service: 'frontend' } },
|
||||
];
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ groupByPerQuery: makeGroupByPerQuery('host', 'service') },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
|
||||
// First call: source groups by host → matches series 1.
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
groupByPerQuery: hostGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Second call: source now groups by service → matches series 2.
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
groupByPerQuery: serviceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
service: 'frontend',
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,10 @@
|
||||
border-color: var(--l1-border);
|
||||
margin: 0;
|
||||
}
|
||||
.dropdown-icon {
|
||||
margin-right: 4px;
|
||||
.dropdown-trigger-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Divider, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { CSSProperties } from 'styled-components';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
@@ -21,16 +21,6 @@ import RenameModal from './RenameModal';
|
||||
|
||||
import './ActionButtons.styles.scss';
|
||||
|
||||
const menuItemStyle: CSSProperties = {
|
||||
fontSize: '14px',
|
||||
letterSpacing: '0.14px',
|
||||
};
|
||||
|
||||
const menuItemStyleV2: CSSProperties = {
|
||||
fontSize: '13px',
|
||||
letterSpacing: '0.13px',
|
||||
};
|
||||
|
||||
function AlertActionButtons({
|
||||
ruleId,
|
||||
alertDetails,
|
||||
@@ -68,9 +58,7 @@ function AlertActionButtons({
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const finalMenuItemStyle = isV2Alert ? menuItemStyleV2 : menuItemStyle;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
const menuItems: MenuItem[] = [
|
||||
...(!isV2Alert
|
||||
? [
|
||||
{
|
||||
@@ -78,7 +66,6 @@ function AlertActionButtons({
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -87,17 +74,13 @@ function AlertActionButtons({
|
||||
label: 'Duplicate',
|
||||
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleAlertDuplicate,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
{
|
||||
key: 'delete-rule',
|
||||
label: 'Delete',
|
||||
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
|
||||
onClick: handleAlertDelete,
|
||||
style: {
|
||||
...finalMenuItemStyle,
|
||||
color: Color.BG_CHERRY_400,
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,16 +123,21 @@ function AlertActionButtons({
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown trigger={['click']} menu={{ items: menuItems }}>
|
||||
<Tooltip title="More options">
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
cursor="pointer"
|
||||
className="dropdown-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
|
||||
<RenameModal
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Dock, PanelBottom, PanelRight } from '@signozhq/icons';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SpanDetailVariant } from './constants';
|
||||
|
||||
interface DockOption {
|
||||
value: SpanDetailVariant;
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const DOCK_OPTIONS: DockOption[] = [
|
||||
{
|
||||
value: SpanDetailVariant.DIALOG,
|
||||
icon: <Dock size={14} />,
|
||||
tooltip: 'Open as floating panel',
|
||||
},
|
||||
{
|
||||
value: SpanDetailVariant.DOCKED,
|
||||
icon: <PanelBottom size={14} />,
|
||||
tooltip: 'Dock at the bottom',
|
||||
},
|
||||
{
|
||||
value: SpanDetailVariant.DOCKED_RIGHT,
|
||||
icon: <PanelRight size={14} />,
|
||||
tooltip: 'Dock on the right',
|
||||
},
|
||||
];
|
||||
|
||||
interface DockModeSwitcherProps {
|
||||
value: SpanDetailVariant;
|
||||
onChange: (value: SpanDetailVariant) => void;
|
||||
tooltipClassName?: string;
|
||||
}
|
||||
|
||||
function DockModeSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
tooltipClassName,
|
||||
}: DockModeSwitcherProps): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
onChange={(v): void => {
|
||||
if (v) {
|
||||
onChange(v as SpanDetailVariant);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{DOCK_OPTIONS.map((option) => (
|
||||
<TooltipRoot key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClassName}>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DockModeSwitcher;
|
||||
@@ -3,10 +3,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
:global(.details-header) {
|
||||
height: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
Dock,
|
||||
Link2,
|
||||
List,
|
||||
PanelBottom,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
@@ -52,7 +61,6 @@ import {
|
||||
SpanDetailVariant,
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
@@ -484,14 +492,31 @@ function SpanDetailsPanel({
|
||||
];
|
||||
|
||||
if (onVariantChange) {
|
||||
const isDocked = variant === SpanDetailVariant.DOCKED;
|
||||
actions.push({
|
||||
key: 'dock-mode',
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<DockModeSwitcher
|
||||
value={variant}
|
||||
onChange={onVariantChange}
|
||||
tooltipClassName={styles.dockToggleTooltip}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.dockToggleTooltip}>
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -528,10 +553,7 @@ function SpanDetailsPanel({
|
||||
</>
|
||||
);
|
||||
|
||||
if (
|
||||
variant === SpanDetailVariant.DOCKED ||
|
||||
variant === SpanDetailVariant.DOCKED_RIGHT
|
||||
) {
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return <div className={styles.root}>{content}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export enum SpanDetailVariant {
|
||||
DRAWER = 'drawer',
|
||||
DIALOG = 'dialog',
|
||||
DOCKED = 'docked',
|
||||
DOCKED_RIGHT = 'right',
|
||||
}
|
||||
|
||||
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
|
||||
|
||||
@@ -4,28 +4,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
|
||||
// collapse panels.
|
||||
.flameCollapse,
|
||||
|
||||
@@ -893,7 +893,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
/>
|
||||
{/* Left panel - table with horizontal scroll */}
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
direction="horizontal"
|
||||
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
|
||||
minWidth={MIN_SIDEBAR_WIDTH}
|
||||
maxWidth={MAX_SIDEBAR_WIDTH}
|
||||
|
||||
@@ -242,13 +242,9 @@ function TraceDetailsV3(): JSX.Element {
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
@@ -295,9 +291,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
@@ -338,118 +332,94 @@ function TraceDetailsV3(): JSX.Element {
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
{panelState.isOpen && !isDocked && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Form,
|
||||
MenuProps,
|
||||
Space,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Button, Divider, Form, Space, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import cx from 'classnames';
|
||||
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
||||
@@ -44,16 +37,22 @@ function FunnelStep({
|
||||
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||
(option) => ({
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
style:
|
||||
option.value === stepData.latency_pointer
|
||||
? { backgroundColor: 'var(--bg-slate-100)' }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
const latencyPointerItems: MenuItem[] = [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: stepData.latency_pointer,
|
||||
onChange: (value): void =>
|
||||
onStepChange(index, {
|
||||
latency_pointer: value as FunnelStepData['latency_pointer'],
|
||||
}),
|
||||
children: LatencyPointers.map((option) => ({
|
||||
type: 'radio',
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
value: option.value,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
@@ -211,17 +210,18 @@ function FunnelStep({
|
||||
</div>
|
||||
<div className="latency-pointer">
|
||||
<div className="latency-pointer__label">Latency pointer</div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: latencyPointerItems,
|
||||
onClick: ({ key }): void =>
|
||||
onStepChange(index, {
|
||||
latency_pointer: key as FunnelStepData['latency_pointer'],
|
||||
}),
|
||||
}}
|
||||
trigger={['click']}
|
||||
disabled={!hasEditPermission}
|
||||
>
|
||||
{hasEditPermission ? (
|
||||
<DropdownMenuSimple menu={{ items: latencyPointerItems }}>
|
||||
<Space>
|
||||
{
|
||||
LatencyPointers.find(
|
||||
(option) => option.value === stepData.latency_pointer,
|
||||
)?.key
|
||||
}
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</DropdownMenuSimple>
|
||||
) : (
|
||||
<Space>
|
||||
{
|
||||
LatencyPointers.find(
|
||||
@@ -230,7 +230,7 @@ function FunnelStep({
|
||||
}
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -23,36 +23,20 @@
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--top,
|
||||
&--bottom {
|
||||
&--vertical {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
&--left,
|
||||
&--right {
|
||||
&--horizontal {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,9 @@ import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
|
||||
export type ResizableBoxHandle = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
export interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
// Which edge the resize handle sits on. The edge determines the axis:
|
||||
// 'top'/'bottom' → vertical resize (height), 'left'/'right' → horizontal
|
||||
// resize (width). Dragging the handle away from the content grows the box;
|
||||
// dragging it toward the content shrinks it.
|
||||
handle?: ResizableBoxHandle;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
@@ -24,7 +18,7 @@ export interface ResizableBoxProps {
|
||||
|
||||
function ResizableBox({
|
||||
children,
|
||||
handle = 'bottom',
|
||||
direction = 'vertical',
|
||||
defaultHeight = 200,
|
||||
minHeight = 50,
|
||||
maxHeight = Infinity,
|
||||
@@ -35,8 +29,7 @@ function ResizableBox({
|
||||
disabled = false,
|
||||
className,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = handle === 'left' || handle === 'right';
|
||||
const isStartHandle = handle === 'top' || handle === 'left';
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -47,13 +40,10 @@ function ResizableBox({
|
||||
const startSize = size;
|
||||
const min = isHorizontal ? minWidth : minHeight;
|
||||
const max = isHorizontal ? maxWidth : maxHeight;
|
||||
// Start-edge handle: pointer moving away from content (negative delta)
|
||||
// grows the box, so invert the sign.
|
||||
const deltaSign = isStartHandle ? -1 : 1;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = (currentPos - startPos) * deltaSign;
|
||||
const delta = currentPos - startPos;
|
||||
const newSize = Math.min(max, Math.max(min, startSize + delta));
|
||||
setSize(newSize);
|
||||
onResize?.(newSize);
|
||||
@@ -71,16 +61,7 @@ function ResizableBox({
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[
|
||||
size,
|
||||
isHorizontal,
|
||||
isStartHandle,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onResize,
|
||||
],
|
||||
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
|
||||
);
|
||||
|
||||
const containerStyle = disabled
|
||||
@@ -88,7 +69,7 @@ function ResizableBox({
|
||||
: isHorizontal
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${handle}`;
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { compose, Store } from 'redux';
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -8,7 +7,6 @@ declare global {
|
||||
pylon: any;
|
||||
Appcues: Record<string, any>;
|
||||
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
|
||||
signozBootData?: { settings: WebSettings | null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export {};
|
||||
|
||||
type BootData = typeof import('../bootData');
|
||||
|
||||
function loadModule(settings?: object | null): BootData {
|
||||
(window as any).signozBootData =
|
||||
settings !== undefined ? { settings } : undefined;
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).signozBootData;
|
||||
});
|
||||
|
||||
describe('when window.signozBootData is absent', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule();
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is null (injection failed)', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule(null);
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is populated', () => {
|
||||
it('reads posthog enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: true } });
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads posthog enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.posthog.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: true } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('missing sub-namespace defaults to enabled', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData exists but settings is undefined', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
(window as any).signozBootData = {};
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
expect(mod.bootSettings.posthog.enabled).toBe(true);
|
||||
expect(mod.bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
const raw = window.signozBootData?.settings as
|
||||
| Partial<WebSettings>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export const bootSettings: Readonly<WebSettings> = {
|
||||
posthog: { enabled: raw?.posthog?.enabled ?? true },
|
||||
appcues: { enabled: raw?.appcues?.enabled ?? true },
|
||||
};
|
||||
@@ -23,20 +23,6 @@ function devBasePathPlugin(basePath: string): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function devBootDataPlugin(env: Record<string, string>): Plugin {
|
||||
return {
|
||||
name: 'dev-boot-data',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
const settings = {
|
||||
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
|
||||
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
|
||||
};
|
||||
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawMarkdownPlugin(): Plugin {
|
||||
return {
|
||||
name: 'raw-markdown',
|
||||
@@ -61,7 +47,6 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
tsconfigPaths(),
|
||||
rawMarkdownPlugin(),
|
||||
devBasePathPlugin(basePath),
|
||||
devBootDataPlugin(env),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
|
||||
@@ -23,13 +23,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
htmltemplate "html/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -40,39 +34,20 @@ import (
|
||||
|
||||
const (
|
||||
Integration = "email"
|
||||
|
||||
// alertEmailLayoutTemplate is the name of the HTML layout template that
|
||||
// wraps the rendered alert bodies. It is loaded into the notification
|
||||
// template (n.tmpl) from the alertmanager templates config and lives at
|
||||
// templates/alertmanager/email.gotmpl.
|
||||
alertEmailLayoutTemplate = "email.signoz.html"
|
||||
)
|
||||
|
||||
// Email implements a Notifier for email notifications.
|
||||
type Email struct {
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// layoutData is the value passed to the email.signoz.html layout
|
||||
// template. It embeds NotificationTemplateData so templates can reference
|
||||
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
|
||||
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
|
||||
// Title and per-alert Bodies.
|
||||
type layoutData struct {
|
||||
alertmanagertypes.NotificationTemplateData
|
||||
Title string
|
||||
Bodies []htmltemplate.HTML
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
}
|
||||
|
||||
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
|
||||
|
||||
// New returns a new Email notifier. When the email.signoz.html layout is
|
||||
// not defined in t, custom-body alerts fall back to plain <div>-wrapped HTML.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) *Email {
|
||||
// New returns a new Email notifier.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
|
||||
if _, ok := c.Headers["Subject"]; !ok {
|
||||
c.Headers["Subject"] = config.DefaultEmailSubject
|
||||
}
|
||||
@@ -88,7 +63,7 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater
|
||||
if err != nil {
|
||||
h = "localhost.localdomain"
|
||||
}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h, templater: templater}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
|
||||
}
|
||||
|
||||
// auth resolves a string of authentication mechanisms.
|
||||
@@ -224,9 +199,9 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
if ok, mech := c.Extension("AUTH"); ok {
|
||||
auth, err := n.auth(mech)
|
||||
if err != nil && !errors.Is(err, errNoAuthUsernameConfigured) {
|
||||
if err != nil && err != errNoAuthUsernameConfigured {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
|
||||
} else if errors.Is(err, errNoAuthUsernameConfigured) {
|
||||
} else if err == errNoAuthUsernameConfigured {
|
||||
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
|
||||
}
|
||||
if auth != nil {
|
||||
@@ -270,16 +245,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the content for the email. subject, when non-empty, overrides
|
||||
// the configured Subject header for this notification only. We deliberately
|
||||
// do not mutate n.conf.Headers here: the config map is shared across
|
||||
// concurrent notifications to the same receiver.
|
||||
subject, htmlBody, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Send the email headers and body.
|
||||
message, err := c.Data()
|
||||
if err != nil {
|
||||
@@ -297,10 +262,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
for header, t := range n.conf.Headers {
|
||||
if header == "Subject" {
|
||||
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", subject))
|
||||
continue
|
||||
}
|
||||
value, err := n.tmpl.ExecuteTextString(t, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
|
||||
@@ -375,7 +336,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if htmlBody != "" {
|
||||
if len(n.conf.HTML) > 0 {
|
||||
// Html template
|
||||
// Preferred alternative placed last per section 5.1.4 of RFC 2046
|
||||
// https://www.ietf.org/rfc/rfc2046.txt
|
||||
@@ -386,8 +347,12 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(htmlBody))
|
||||
_, err = qw.Write([]byte(body))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
|
||||
}
|
||||
@@ -416,124 +381,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// prepareContent returns a subject override (empty when the default config
|
||||
// Subject should be used) and the HTML body for the email. Callers must treat
|
||||
// the subject as local state and never write it back to n.conf.Headers.
|
||||
func (n *Email) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Headers["Subject"],
|
||||
DefaultBodyTemplate: n.conf.HTML,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
subject := result.Title
|
||||
|
||||
if !result.IsDefaultBody {
|
||||
// Custom-body path: render each expanded markdown body to HTML, then
|
||||
// wrap the whole thing in the email.signoz.html layout (or fall
|
||||
// back to plain <div> wrapping when the layout template is not loaded).
|
||||
for i, body := range result.Body {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
rendered, err := markdownrenderer.RenderHTML(body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result.Body[i] = rendered
|
||||
}
|
||||
appendRelatedLinkButtons(alerts, result.Body)
|
||||
html, err := n.renderLayout(result)
|
||||
if err != nil {
|
||||
n.logger.WarnContext(ctx, "custom email template rendering failed, falling back to plain <div> wrap", errors.Attr(err))
|
||||
return subject, wrapBodiesAsDivs(result.Body), nil
|
||||
}
|
||||
return subject, html, nil
|
||||
}
|
||||
|
||||
return subject, result.Body[0], nil
|
||||
}
|
||||
|
||||
// renderLayout wraps result in the email.signoz.html HTML layout loaded
|
||||
// into n.tmpl from the alertmanager templates config. Returns an error when the
|
||||
// layout template is not defined (e.g. in tests where no templates are loaded)
|
||||
// so prepareContent can fall back to plain <div> wrapping.
|
||||
func (n *Email) renderLayout(result *alertmanagertypes.ExpandResult) (string, error) {
|
||||
bodies := make([]htmltemplate.HTML, 0, len(result.Body))
|
||||
for _, b := range result.Body {
|
||||
bodies = append(bodies, htmltemplate.HTML(b))
|
||||
}
|
||||
data := layoutData{Title: result.Title, Bodies: bodies}
|
||||
if result.NotificationData != nil {
|
||||
data.NotificationTemplateData = *result.NotificationData
|
||||
}
|
||||
html, err := n.tmpl.ExecuteHTMLString(`{{ template "`+alertEmailLayoutTemplate+`" . }}`, data)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to render email layout")
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// appendRelatedLinkButtons appends "View Related Logs/Traces" buttons to each
|
||||
// per-alert body when the rule manager attached the corresponding annotation.
|
||||
// bodies is positionally aligned with alerts (see alertmanagertemplate.Prepare);
|
||||
// empty bodies are skipped so we never attach a button to an alert that produced
|
||||
// no visible content.
|
||||
func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
|
||||
for i := range bodies {
|
||||
if i >= len(alerts) || bodies[i] == "" {
|
||||
continue
|
||||
}
|
||||
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
|
||||
bodies[i] += htmlButton("View Related Logs", string(link))
|
||||
}
|
||||
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
|
||||
bodies[i] += htmlButton("View Related Traces", string(link))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wrapBodiesAsDivs(bodies []string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range bodies {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(part)
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func htmlButton(text, url string) string {
|
||||
return fmt.Sprintf(`
|
||||
<a href="%s" target="_blank" style="text-decoration: none;">
|
||||
<button style="
|
||||
padding: 6px 16px;
|
||||
/* Default System Font */
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
/* Light Theme & Dynamic Background (Solid) */
|
||||
color: #111827;
|
||||
background-color: #f9fafb;
|
||||
/* Static Outline */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">
|
||||
%s
|
||||
</button>
|
||||
</a>`, url, text)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -18,10 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/emersion/go-smtp"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
@@ -46,11 +42,6 @@ const (
|
||||
emailFrom = "alertmanager@example.com"
|
||||
)
|
||||
|
||||
// testTemplater returns a Templater bound to tmpl with a discard logger.
|
||||
func testTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// email represents an email returned by the MailDev REST API.
|
||||
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
|
||||
type email struct {
|
||||
@@ -171,7 +162,7 @@ func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.Email
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
email := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
email := New(cfg, tmpl, promslog.NewNopLogger())
|
||||
|
||||
retry, err := email.Notify(ctx, firingAlert)
|
||||
if err != nil {
|
||||
@@ -715,7 +706,7 @@ func TestEmailRejected(t *testing.T) {
|
||||
tmpl, firingAlert, err := prepare(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
e := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
e := New(cfg, tmpl, promslog.NewNopLogger())
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
@@ -1039,135 +1030,6 @@ func TestEmailImplicitTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default title template; custom body template", func(t *testing.T) {
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
bodyTpl := "line $labels.instance"
|
||||
a1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("one"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
|
||||
},
|
||||
},
|
||||
}
|
||||
a2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("two"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
|
||||
},
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{a1, a2}
|
||||
|
||||
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "subj"}}
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subj", subject)
|
||||
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
|
||||
})
|
||||
|
||||
t.Run("custom title template; default body HTML template", func(t *testing.T) {
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
cfg := &config.EmailConfig{
|
||||
Headers: map[string]string{},
|
||||
HTML: "Status: {{ .Status }}",
|
||||
}
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Status: firing", htmlBody)
|
||||
require.Equal(t, "fixed from firing", subject)
|
||||
})
|
||||
|
||||
t.Run("default template without HTML", func(t *testing.T) {
|
||||
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "the email subject"}}
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", htmlBody)
|
||||
require.Equal(t, "the email subject", subject)
|
||||
})
|
||||
|
||||
t.Run("custom title template; custom body template", func(t *testing.T) {
|
||||
// Load the email.signoz.html layout into the notification template
|
||||
// the same way the alertmanager server does via the templates config.
|
||||
tmpl, err := template.FromGlobs([]string{"../../../../templates/alertmanager/*.gotmpl"})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("two"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue("line $labels.instance"),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
cfg := &config.EmailConfig{
|
||||
Headers: map[string]string{"Subject": "subject"},
|
||||
HTML: "Well, what are you?",
|
||||
}
|
||||
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, htmlBody, "<!DOCTYPE html>")
|
||||
require.Contains(t, htmlBody, "<p>line two</p>")
|
||||
require.NotContains(t, htmlBody, "Well, what are you?")
|
||||
require.Equal(t, subject, "fixed from firing")
|
||||
require.NotContains(t, subject, "subject")
|
||||
})
|
||||
}
|
||||
|
||||
func ptrTo(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -46,7 +44,6 @@ type Notifier struct {
|
||||
retrier *notify.Retrier
|
||||
webhookURL *config.SecretURL
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
|
||||
@@ -55,7 +52,7 @@ type Content struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Body []Body `json:"body"`
|
||||
Msteams Msteams `json:"msteams,omitzero"`
|
||||
Msteams Msteams `json:"msteams,omitempty"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
@@ -97,7 +94,7 @@ type teamsMessage struct {
|
||||
}
|
||||
|
||||
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -112,7 +109,6 @@ func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *s
|
||||
retrier: ¬ify.Retrier{},
|
||||
webhookURL: c.WebhookURL,
|
||||
postJSONFunc: notify.PostJSON,
|
||||
templater: templater,
|
||||
}
|
||||
|
||||
return n, nil
|
||||
@@ -132,11 +128,25 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return false, err
|
||||
}
|
||||
|
||||
title := tmpl(n.conf.Title)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
titleLink := tmpl(n.titleLink)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
alerts := types.Alerts(as...)
|
||||
color := colorGrey
|
||||
switch alerts.Status() {
|
||||
case model.AlertFiring:
|
||||
color = colorRed
|
||||
case model.AlertResolved:
|
||||
color = colorGreen
|
||||
}
|
||||
|
||||
var url string
|
||||
if n.conf.WebhookURL != nil {
|
||||
url = n.conf.WebhookURL.String()
|
||||
@@ -148,12 +158,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
url = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
bodyBlocks, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
|
||||
t := teamsMessage{
|
||||
Type: "message",
|
||||
@@ -165,7 +169,17 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
Type: "AdaptiveCard",
|
||||
Version: "1.2",
|
||||
Body: bodyBlocks,
|
||||
Body: []Body{
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: title,
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Style: "heading",
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
Actions: []Action{
|
||||
{
|
||||
Type: "Action.OpenUrl",
|
||||
@@ -181,6 +195,20 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
},
|
||||
}
|
||||
|
||||
// add labels and annotations to the body of all alerts
|
||||
for _, alert := range as {
|
||||
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, Body{
|
||||
Type: "TextBlock",
|
||||
Text: "Alerts",
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Color: color,
|
||||
})
|
||||
|
||||
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, n.createLabelsAndAnnotationsBody(alert)...)
|
||||
}
|
||||
|
||||
var payload bytes.Buffer
|
||||
if err = json.NewEncoder(&payload).Encode(t); err != nil {
|
||||
return false, err
|
||||
@@ -200,75 +228,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return shouldRetry, err
|
||||
}
|
||||
|
||||
// prepareContent builds the Adaptive Card body blocks for the notification.
|
||||
// The first block is always the title; the remainder depends on whether the
|
||||
// alerts carried a custom body template.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) ([]Body, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
color := colorGrey
|
||||
switch types.Alerts(alerts...).Status() {
|
||||
case model.AlertFiring:
|
||||
color = colorRed
|
||||
case model.AlertResolved:
|
||||
color = colorGreen
|
||||
}
|
||||
|
||||
blocks := []Body{{
|
||||
Type: "TextBlock",
|
||||
Text: result.Title,
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Style: "heading",
|
||||
Color: color,
|
||||
}}
|
||||
|
||||
if result.IsDefaultBody {
|
||||
for _, alert := range alerts {
|
||||
blocks = append(blocks, Body{
|
||||
Type: "TextBlock",
|
||||
Text: "Alerts",
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Color: color,
|
||||
})
|
||||
blocks = append(blocks, n.createLabelsAndAnnotationsBody(alert)...)
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// Custom body path: result.Body is positionally aligned with alerts;
|
||||
// entries for alerts whose template rendered empty are kept as "" so we
|
||||
// can skip them here without shifting the per-alert color index.
|
||||
for i, body := range result.Body {
|
||||
if body == "" || i >= len(alerts) {
|
||||
continue
|
||||
}
|
||||
perAlertColor := colorRed
|
||||
if alerts[i].Resolved() {
|
||||
perAlertColor = colorGreen
|
||||
}
|
||||
blocks = append(blocks, Body{
|
||||
Type: "TextBlock",
|
||||
Text: body,
|
||||
Wrap: true,
|
||||
Color: perAlertColor,
|
||||
})
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
|
||||
bodies := []Body{}
|
||||
bodies = append(bodies, Body{
|
||||
@@ -299,8 +258,7 @@ func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
|
||||
|
||||
annotationsFacts := []Fact{}
|
||||
for k, v := range alert.Annotations {
|
||||
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) ||
|
||||
alertmanagertypes.IsPrivateAnnotation(string(k)) {
|
||||
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) {
|
||||
continue
|
||||
}
|
||||
annotationsFacts = append(annotationsFacts, Fact{Title: string(k), Value: string(v)})
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -16,9 +15,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -27,28 +23,21 @@ import (
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// This is a test URL that has been modified to not be valid.
|
||||
var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx")
|
||||
|
||||
func TestMSTeamsV2Retry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -75,16 +64,14 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -166,8 +153,7 @@ func TestMSTeamsV2Templating(t *testing.T) {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(tc.cfg, tmpl, tc.titleLink, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), tc.titleLink, promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -200,124 +186,20 @@ func TestMSTeamsV2RedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template - firing alerts", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
Title: "Alertname: {{ .CommonLabels.alertname }}",
|
||||
},
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test"},
|
||||
// Custom body template
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "Firing alert: $alertname",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
blocks, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blocks)
|
||||
// First block should be the title with color (firing = red)
|
||||
require.Equal(t, "Bolder", blocks[0].Weight)
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Alertname: test", blocks[0].Text)
|
||||
// verify body text
|
||||
require.Equal(t, "Firing alert: test", blocks[1].Text)
|
||||
})
|
||||
|
||||
t.Run("custom template - per-alert color", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test1"},
|
||||
Annotations: model.LabelSet{
|
||||
"summary": "test",
|
||||
ruletypes.AnnotationTitleTemplate: "Custom Title",
|
||||
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test2"},
|
||||
Annotations: model.LabelSet{
|
||||
"summary": "test",
|
||||
ruletypes.AnnotationTitleTemplate: "Custom Title",
|
||||
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
|
||||
},
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Minute),
|
||||
},
|
||||
},
|
||||
}
|
||||
blocks, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blocks)
|
||||
// total 3 blocks: title and 2 body blocks
|
||||
require.True(t, len(blocks) == 3)
|
||||
// First block: title color is overall color of the alerts
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Custom Title", blocks[0].Text)
|
||||
// Body blocks should have per-alert color
|
||||
require.Equal(t, colorRed, blocks[1].Color) // firing
|
||||
require.Equal(t, colorGreen, blocks[2].Color) // resolved
|
||||
})
|
||||
}
|
||||
|
||||
func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
@@ -327,16 +209,14 @@ func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -37,27 +34,25 @@ const maxMessageLenRunes = 130
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, 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}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -128,55 +123,6 @@ func safeSplit(s, sep string) []string {
|
||||
return b
|
||||
}
|
||||
|
||||
// prepareContent expands alert templates and returns the OpsGenie-ready title
|
||||
// (truncated to the 130-rune limit) and HTML description. Custom bodies are
|
||||
// rendered to HTML and stitched together with <hr> dividers; default bodies
|
||||
// are joined with newlines (OpsGenie's legacy plain-text description).
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (string, 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 description string
|
||||
if result.IsDefaultBody {
|
||||
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 = b.String()
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
return title, description, 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)
|
||||
@@ -222,10 +168,9 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
default:
|
||||
message, description, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return nil, false, err
|
||||
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
createEndpointURL := n.conf.APIURL.Copy()
|
||||
@@ -264,7 +209,7 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
msg := &opsGenieCreateMessage{
|
||||
Alias: alias,
|
||||
Message: message,
|
||||
Description: description,
|
||||
Description: tmpl(n.conf.Description),
|
||||
Details: details,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Responders: responders,
|
||||
|
||||
@@ -8,16 +8,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -26,23 +22,16 @@ import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestOpsGenieRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -58,16 +47,14 @@ func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -85,16 +72,14 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -217,7 +202,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)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -293,7 +278,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())
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
@@ -336,7 +321,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())
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
@@ -344,99 +329,6 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := promslog.NewNopLogger()
|
||||
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
templater: newTestTemplater(tmpl),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"Message": "Firing alert: test",
|
||||
"Description": "Check runbook for more details",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{alert}
|
||||
|
||||
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, prepErr)
|
||||
require.Equal(t, "Firing alert: test", title)
|
||||
require.Equal(t, "Check runbook for more details", desc)
|
||||
})
|
||||
|
||||
t.Run("custom template", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := promslog.NewNopLogger()
|
||||
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
templater: newTestTemplater(tmpl),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"service": "payment",
|
||||
"namespace": "potter-the-harry",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
|
||||
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alert2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"service": "payment",
|
||||
"namespace": "smart-the-rat",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
|
||||
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{alert1, alert2}
|
||||
|
||||
title, desc, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "High request throughput for payment", title)
|
||||
// Each alert body wrapped in <div>, separated by <hr>
|
||||
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
|
||||
})
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/alecthomas/units"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
@@ -42,22 +40,21 @@ const (
|
||||
|
||||
// Notifier implements a Notifier for PagerDuty notifications.
|
||||
type Notifier struct {
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new PagerDuty notifier.
|
||||
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client, templater: templater}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
|
||||
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
|
||||
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
|
||||
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
|
||||
@@ -146,12 +143,11 @@ func (n *Notifier) notifyV1(
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
title string,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
description, truncated := notify.TruncateInRunes(title, maxV1DescriptionLenRunes)
|
||||
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
|
||||
}
|
||||
@@ -207,7 +203,6 @@ func (n *Notifier) notifyV2(
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
title string,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
@@ -216,7 +211,7 @@ func (n *Notifier) notifyV2(
|
||||
n.conf.Severity = "error"
|
||||
}
|
||||
|
||||
summary, truncated := notify.TruncateInRunes(title, maxV2SummaryLenRunes)
|
||||
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
|
||||
}
|
||||
@@ -299,22 +294,6 @@ func (n *Notifier) notifyV2(
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// prepareTitle expands the notification title. PagerDuty has no body surface
|
||||
// we care about — the description/summary field is what users see as the
|
||||
// incident headline, so we feed the configured Description as the default
|
||||
// title template and ignore any custom body_template entirely.
|
||||
func (n *Notifier) prepareTitle(ctx context.Context, alerts []*types.Alert) (string, error) {
|
||||
customTitle, _ := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
DefaultTitleTemplate: n.conf.Description,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Title, nil
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
@@ -323,12 +302,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
|
||||
title, err := n.prepareTitle(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
var (
|
||||
alerts = types.Alerts(as...)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
@@ -356,7 +329,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
if n.apiV1 != "" {
|
||||
nf = n.notifyV1
|
||||
}
|
||||
retry, err := nf(ctx, eventType, key, data, details, title)
|
||||
retry, err := nf(ctx, eventType, key, data, details)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -18,10 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -34,20 +30,14 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV1(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -59,15 +49,13 @@ func TestPagerDutyRetryV1(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV2(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -83,15 +71,13 @@ func TestPagerDutyRedactedURLV1(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
@@ -104,16 +90,14 @@ func TestPagerDutyRedactedURLV2(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -130,15 +114,13 @@ func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
@@ -156,16 +138,14 @@ func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -322,8 +302,7 @@ func TestPagerDutyTemplating(t *testing.T) {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.URL = &config.URL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(tc.cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
if pd.apiV1 != "" {
|
||||
pd.apiV1 = u.String()
|
||||
@@ -413,15 +392,13 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
Details: bigDetailsV1,
|
||||
}
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifierV1, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -443,9 +420,8 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -560,8 +536,7 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
|
||||
Links: links,
|
||||
}
|
||||
|
||||
pdTmpl := test.CreateTmpl(t)
|
||||
pagerDuty, err := New(&pagerDutyConfig, pdTmpl, promslog.NewNopLogger(), newTestTemplater(pdTmpl))
|
||||
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -628,8 +603,7 @@ func TestPagerDutyTimeout(t *testing.T) {
|
||||
Timeout: tt.timeout,
|
||||
}
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(&cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -907,79 +881,3 @@ func TestRenderDetails(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
prepareContext := func() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
ctx = notify.WithReceiverName(ctx, "test-receiver")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "HighCPU for Payment service"})
|
||||
return ctx
|
||||
}
|
||||
t.Run("default template uses go text template config for title", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
Description: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
|
||||
},
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := prepareContext()
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "HighCPU for Payment service"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
|
||||
})
|
||||
|
||||
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := prepareContext()
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "HighCPU",
|
||||
"service": "api-server",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "$rule.name on $service is in $alert.status state",
|
||||
},
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU on api-server is in resolved state", title)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var customNotifierIntegrations = []string{
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,25 +53,23 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
|
||||
}
|
||||
|
||||
for i, c := range nc.WebhookConfigs {
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, templater) })
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.EmailConfigs {
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return email.New(c, tmpl, l, templater), nil
|
||||
})
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
|
||||
}
|
||||
for i, c := range nc.PagerdutyConfigs {
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
|
||||
}
|
||||
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) })
|
||||
}
|
||||
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) })
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.MSTeamsV2Configs {
|
||||
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -29,8 +25,6 @@ import (
|
||||
|
||||
const (
|
||||
Integration = "slack"
|
||||
colorRed = "#FF0000"
|
||||
colorGreen = "#00FF00"
|
||||
)
|
||||
|
||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
||||
@@ -38,18 +32,17 @@ const maxTitleLenRunes = 1024
|
||||
|
||||
// Notifier implements a Notifier for Slack notifications.
|
||||
type Notifier struct {
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
}
|
||||
|
||||
// New returns a new Slack notification handler.
|
||||
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -61,7 +54,6 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
postJSONFunc: notify.PostJSON,
|
||||
}, nil
|
||||
}
|
||||
@@ -89,10 +81,9 @@ type attachment struct {
|
||||
Actions []config.SlackAction `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
Footer string `json:"footer,omitempty"`
|
||||
Footer string `json:"footer"`
|
||||
Color string `json:"color,omitempty"`
|
||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
|
||||
Blocks []any `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
@@ -109,15 +100,79 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
tmplText = notify.TmplText(n.tmpl, data, &err)
|
||||
)
|
||||
var markdownIn []string
|
||||
|
||||
attachments, err := n.prepareContent(ctx, as, tmplText)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
}
|
||||
|
||||
if len(attachments) > 0 {
|
||||
n.addFieldsAndActions(&attachments[0], tmplText)
|
||||
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
att := &attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: tmplText(n.conf.Text),
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
}
|
||||
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
// Check if short was defined for the field otherwise fallback to the global setting
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
|
||||
// Rebuild the field by executing any templates and setting the new value for short
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
}
|
||||
|
||||
req := &request{
|
||||
@@ -127,7 +182,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
IconURL: tmplText(n.conf.IconURL),
|
||||
LinkNames: n.conf.LinkNames,
|
||||
Text: tmplText(n.conf.MessageText),
|
||||
Attachments: attachments,
|
||||
Attachments: []attachment{*att},
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -183,150 +238,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
// prepareContent expands alert templates and returns the Slack attachment(s)
|
||||
// ready to send. When alerts carry a custom body template, one title-only
|
||||
// attachment plus one body attachment per alert is returned so that each alert
|
||||
// can get its own firing/resolved color and per-alert action buttons.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tmplText func(string) string) ([]attachment, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxTitleLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
|
||||
if result.IsDefaultBody {
|
||||
var markdownIn []string
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
}
|
||||
return []attachment{
|
||||
{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: result.Body[0],
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Custom template path: one title attachment + one attachment per
|
||||
// non-empty alert body. result.Body is positionally aligned with alerts,
|
||||
// so we index alerts[i] directly and skip empty entries.
|
||||
attachments := make([]attachment, 0, 1+len(result.Body))
|
||||
attachments = append(attachments, attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
})
|
||||
|
||||
for i, body := range result.Body {
|
||||
if body == "" || i >= len(alerts) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Custom bodies are authored in markdown; render each non-empty body to
|
||||
// Slack's mrkdwn flavour. Default bodies skip this because the Text
|
||||
// template is already channel-ready.
|
||||
rendered, renderErr := markdownrenderer.RenderSlackMrkdwn(body)
|
||||
if renderErr != nil {
|
||||
return nil, renderErr
|
||||
}
|
||||
|
||||
color := colorRed
|
||||
if alerts[i].Resolved() {
|
||||
color = colorGreen
|
||||
}
|
||||
attachments = append(attachments, attachment{
|
||||
Text: rendered,
|
||||
Color: color,
|
||||
MrkdwnIn: []string{"text"},
|
||||
Actions: buildRelatedLinkActions(alerts[i]),
|
||||
})
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
// buildRelatedLinkActions returns the "View Related Logs/Traces" action
|
||||
// buttons for an alert, or nil when no related-link annotations are present.
|
||||
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
|
||||
var actions []config.SlackAction
|
||||
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
|
||||
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
|
||||
}
|
||||
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
|
||||
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// addFieldsAndActions populates fields and actions on the attachment from the Slack config.
|
||||
func (n *Notifier) addFieldsAndActions(att *attachment, tmplText func(string) string) {
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
// checkResponseError parses out the error message from Slack API response.
|
||||
func checkResponseError(resp *http.Response) (bool, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -17,9 +17,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -32,19 +29,13 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestSlackRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -58,15 +49,13 @@ func TestSlackRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -82,15 +71,13 @@ func TestGettingSlackURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String())
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -106,15 +93,13 @@ func TestTrimmingSlackURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -199,7 +184,6 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiurl, _ := url.Parse("https://slack.com/post.Message")
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
@@ -207,9 +191,8 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
APIURL: &config.SecretURL{URL: apiurl},
|
||||
Channel: "channelname",
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -259,7 +242,6 @@ func TestSlackTimeout(t *testing.T) {
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
u, _ := url.Parse("https://slack.com/post.Message")
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
@@ -268,9 +250,8 @@ func TestSlackTimeout(t *testing.T) {
|
||||
Channel: "channelname",
|
||||
Timeout: tt.timeout,
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
@@ -301,225 +282,6 @@ func TestSlackTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestContext creates a context with group key, receiver name, and group labels
|
||||
// required by the notification processor.
|
||||
func setupTestContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group")
|
||||
ctx = notify.WithReceiverName(ctx, "slack")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
|
||||
"alertname": "TestAlert",
|
||||
"severity": "critical",
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template uses go text template config for title and body", func(t *testing.T) {
|
||||
// When alerts have no custom annotation templates (title_template / body_template),
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
|
||||
Text: `{{ range .Alerts }}Alert: {{ .Labels.alertname }} - severity {{ .Labels.severity }}{{ end }}`,
|
||||
Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`,
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
|
||||
ctx := setupTestContext()
|
||||
alerts := []*types.Alert{
|
||||
{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
}},
|
||||
}
|
||||
|
||||
// Build tmplText the same way Notify does
|
||||
var err error
|
||||
data := notify.GetTemplateData(ctx, tmpl, alerts, slog.New(slog.DiscardHandler))
|
||||
tmplText := notify.TmplText(tmpl, data, &err)
|
||||
|
||||
atts, attErr := notifier.prepareContent(ctx, alerts, tmplText)
|
||||
require.NoError(t, attErr)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, atts, 1)
|
||||
|
||||
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
|
||||
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
|
||||
// Color is templated — firing alert should be "danger"
|
||||
require.Equal(t, "danger", atts[0].Color)
|
||||
// No BlockKit blocks for default template
|
||||
require.Nil(t, atts[0].Blocks)
|
||||
// Default markdownIn when config has none
|
||||
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
|
||||
})
|
||||
|
||||
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
|
||||
// When alerts carry custom $variable annotation templates (title_template / body_template)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: "default title fallback",
|
||||
Text: "default text fallback",
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
tmplText := func(s string) string { return s }
|
||||
|
||||
bodyTemplate := `## $rule.name
|
||||
|
||||
**Service:** *$labels.service*
|
||||
**Instance:** *$labels.instance*
|
||||
**Region:** *$labels.region*
|
||||
**Method:** *$labels.http_method*
|
||||
|
||||
---
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Current** | *$value* |
|
||||
| **Threshold** | *$threshold.value* |
|
||||
|
||||
**Status:** $alert.status | **Severity:** $labels.severity`
|
||||
titleTemplate := "[$alert.status] $rule.name — $labels.service"
|
||||
|
||||
ctx := setupTestContext()
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "100",
|
||||
"threshold.value": "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
resolvedAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
|
||||
StartsAt: time.Now().Add(-2 * time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "50",
|
||||
"threshold.value": "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
atts, err := notifier.prepareContent(ctx, []*types.Alert{firingAlert, resolvedAlert}, tmplText)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 1 title attachment + 2 body attachments (one per alert)
|
||||
require.Len(t, atts, 3)
|
||||
|
||||
// First attachment: title-only, no color, no blocks
|
||||
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
|
||||
require.Empty(t, atts[0].Color)
|
||||
require.Nil(t, atts[0].Blocks)
|
||||
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
|
||||
|
||||
expectedFiringBody := "*HighCPU*\n\n" +
|
||||
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
|
||||
"---\n\n" +
|
||||
"```\nMetric | Value\n----------|------\nCurrent | 100 \nThreshold | 200 \n```\n\n" +
|
||||
"*Status:* firing | *Severity:* critical\n\n"
|
||||
|
||||
expectedResolvedBody := "*HighCPU*\n\n" +
|
||||
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
|
||||
"---\n\n" +
|
||||
"```\nMetric | Value\n----------|------\nCurrent | 50 \nThreshold | 200 \n```\n\n" +
|
||||
"*Status:* resolved | *Severity:* critical\n\n"
|
||||
|
||||
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
|
||||
require.Nil(t, atts[1].Blocks)
|
||||
require.Equal(t, "#FF0000", atts[1].Color)
|
||||
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
|
||||
require.Equal(t, expectedFiringBody, atts[1].Text)
|
||||
|
||||
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
|
||||
require.Nil(t, atts[2].Blocks)
|
||||
require.Equal(t, "#00FF00", atts[2].Color)
|
||||
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
|
||||
require.Equal(t, expectedResolvedBody, atts[2].Text)
|
||||
})
|
||||
|
||||
t.Run("default template with fields and actions", func(t *testing.T) {
|
||||
// Verifies that addFieldsAndActions (called from Notify after prepareContent)
|
||||
// correctly populates fields and actions on the attachment from config.
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
short := true
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: `{{ .CommonLabels.alertname }}`,
|
||||
Text: "alert text",
|
||||
Color: "warning",
|
||||
Fields: []*config.SlackField{
|
||||
{Title: "Severity", Value: "critical", Short: &short},
|
||||
{Title: "Service", Value: "api-server", Short: &short},
|
||||
},
|
||||
Actions: []*config.SlackAction{
|
||||
{Type: "button", Text: "View Alert", URL: "https://alertmanager.signoz.com"},
|
||||
},
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
tmplText := func(s string) string { return s }
|
||||
|
||||
ctx := setupTestContext()
|
||||
alerts := []*types.Alert{
|
||||
{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "TestAlert"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
}},
|
||||
}
|
||||
atts, err := notifier.prepareContent(ctx, alerts, tmplText)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, atts, 1)
|
||||
|
||||
// prepareContent does not populate fields/actions — that's done by
|
||||
// addFieldsAndActions which is called from Notify.
|
||||
require.Nil(t, atts[0].Fields)
|
||||
require.Nil(t, atts[0].Actions)
|
||||
|
||||
// Simulate what Notify does after prepareContent
|
||||
notifier.addFieldsAndActions(&atts[0], tmplText)
|
||||
|
||||
// Verify fields
|
||||
require.Len(t, atts[0].Fields, 2)
|
||||
require.Equal(t, "Severity", atts[0].Fields[0].Title)
|
||||
require.Equal(t, "critical", atts[0].Fields[0].Value)
|
||||
require.True(t, *atts[0].Fields[0].Short)
|
||||
require.Equal(t, "Service", atts[0].Fields[1].Title)
|
||||
require.Equal(t, "api-server", atts[0].Fields[1].Value)
|
||||
|
||||
// Verify actions
|
||||
require.Len(t, atts[0].Actions, 1)
|
||||
require.Equal(t, "button", atts[0].Actions[0].Type)
|
||||
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
|
||||
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlackMessageField(t *testing.T) {
|
||||
// 1. Setup a fake Slack server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -567,7 +329,7 @@ func TestSlackMessageField(t *testing.T) {
|
||||
tmpl.ExternalURL = u
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
|
||||
notifier, err := New(conf, tmpl, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -29,16 +28,15 @@ const (
|
||||
|
||||
// Notifier implements a Notifier for generic webhooks.
|
||||
type Notifier struct {
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new Webhook.
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,8 +48,7 @@ func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templ
|
||||
client: client,
|
||||
// Webhooks are assumed to respond with 2xx response codes on a successful
|
||||
// request and 5xx response codes are assumed to be recoverable.
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
retrier: ¬ify.Retrier{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
@@ -29,15 +27,13 @@ import (
|
||||
)
|
||||
|
||||
func TestWebhookRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL("http://example.com"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
@@ -100,16 +96,13 @@ func TestWebhookRedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := alertmanagertemplate.New(tmpl, slog.Default())
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(u.String()),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
templater,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -125,15 +118,13 @@ func TestWebhookReadingURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -187,15 +178,13 @@ func TestWebhookURLTemplating(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calledURL = "" // Reset for each test
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(tc.url),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -28,13 +28,6 @@ type Config struct {
|
||||
|
||||
// Configuration for the notification log.
|
||||
NFLog NFLogConfig `mapstructure:"nflog"`
|
||||
|
||||
// Templates is the list of globs from which SigNoz's alertmanager notification
|
||||
// templates are loaded (e.g. the email.signoz.html layout). This mirrors the
|
||||
// upstream alertmanager `templates` config option (https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/config/config.go#L412).
|
||||
// The upstream default templates (default.tmpl, email.tmpl) are always loaded
|
||||
// from the embedded alertmanager assets, so only SigNoz's own templates are listed here.
|
||||
Templates []string `mapstructure:"templates"`
|
||||
}
|
||||
|
||||
type AlertsConfig struct {
|
||||
@@ -107,6 +100,5 @@ func NewConfig() Config {
|
||||
MaintenanceInterval: 15 * time.Minute,
|
||||
Retention: 120 * time.Hour,
|
||||
},
|
||||
Templates: []string{"/root/templates/alertmanager/*.gotmpl"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,7 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
|
||||
}
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -68,14 +61,7 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
|
||||
var ids []string
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
ids = append(ids, mw.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
"github.com/prometheus/alertmanager/featurecontrol"
|
||||
"github.com/prometheus/alertmanager/inhibit"
|
||||
@@ -24,8 +23,8 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
)
|
||||
|
||||
@@ -66,7 +65,6 @@ type Server struct {
|
||||
muter *MaintenanceMuter
|
||||
marker *types.MemMarker
|
||||
tmpl *template.Template
|
||||
templater alertmanagertypes.Templater
|
||||
wg sync.WaitGroup
|
||||
stopc chan struct{}
|
||||
notificationManager nfmanager.NotificationManager
|
||||
@@ -244,21 +242,13 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
config := alertmanagerConfig.AlertmanagerConfig()
|
||||
|
||||
var err error
|
||||
// Load SigNoz's alertmanager notification templates from the configured
|
||||
// globs. The upstream default templates (default.tmpl, email.tmpl) are
|
||||
// always loaded from the embedded alertmanager assets inside FromGlobs, so
|
||||
// only SigNoz's own templates (e.g. the email.signoz.html layout) are listed
|
||||
// here. The upstream config.Templates field is not used: SigNoz never
|
||||
// populates it (there is no per-org template configuration).
|
||||
server.tmpl, err = alertmanagertypes.FromGlobs(server.srvConfig.Templates)
|
||||
server.tmpl, err = alertmanagertypes.FromGlobs(config.Templates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server.tmpl.ExternalURL = server.srvConfig.ExternalURL
|
||||
|
||||
server.templater = alertmanagertemplate.New(server.tmpl, server.logger)
|
||||
|
||||
// Build the routing tree and record which receivers are used.
|
||||
routes := dispatch.NewRoute(config.Route, nil)
|
||||
activeReceivers := make(map[string]struct{})
|
||||
@@ -275,7 +265,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
|
||||
continue
|
||||
}
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -352,7 +342,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
|
||||
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
|
||||
}
|
||||
|
||||
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
|
||||
@@ -435,7 +425,6 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
|
||||
server.alertmanagerConfig,
|
||||
server.tmpl,
|
||||
server.logger,
|
||||
server.templater,
|
||||
group.groupLabels,
|
||||
group.alerts...,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU critical alerts to webhook",
|
||||
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU warning alerts to webhook",
|
||||
@@ -87,25 +87,18 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", ¬ifConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
activeSchedule := &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
}
|
||||
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
|
||||
mwRuleIDAndScope := valuer.GenerateUUID()
|
||||
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
|
||||
mwRuleIDOnly := valuer.GenerateUUID()
|
||||
// mwScopeOnly: all critical alerts regardless of rule ID.
|
||||
mwScopeOnly := valuer.GenerateUUID()
|
||||
|
||||
mwID := valuer.GenerateUUID()
|
||||
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
|
||||
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
|
||||
[]*alertmanagertypes.PlannedMaintenance{
|
||||
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
|
||||
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
|
||||
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
|
||||
}, nil,
|
||||
[]*alertmanagertypes.PlannedMaintenance{{
|
||||
ID: mwID,
|
||||
Schedule: &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
},
|
||||
RuleIDs: []string{"high-cpu-usage"},
|
||||
}}, nil,
|
||||
)
|
||||
|
||||
srvCfg := NewConfig()
|
||||
@@ -256,42 +249,18 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
|
||||
// Window with ruleID + scope mutes only alerts matching both.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
|
||||
// Window with ruleID but no scope mutes all severities for that rule.
|
||||
t.Run("verify_muting", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_scope_only", func(t *testing.T) {
|
||||
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
require.Empty(t, alert.Status.MutedBy)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user