mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-09 13:40:25 +01:00
Compare commits
15 Commits
tvats-alia
...
fix/error-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b847a7e3 | ||
|
|
349b98c073 | ||
|
|
19f079dc82 | ||
|
|
926bf1d6e2 | ||
|
|
e19b9e689d | ||
|
|
70b08112f8 | ||
|
|
72ce8768b3 | ||
|
|
7c1fe82043 | ||
|
|
d4dbbceab7 | ||
|
|
918cf4dfe5 | ||
|
|
c729ed2637 | ||
|
|
43f7363e84 | ||
|
|
6fe69b94c9 | ||
|
|
1eb27d0bc4 | ||
|
|
55cce3c708 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,6 +51,8 @@ ee/query-service/tests/test-deploy/data/
|
||||
# local data
|
||||
*.backup
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/db
|
||||
/deploy/docker/clickhouse-setup/data/
|
||||
/deploy/docker-swarm/clickhouse-setup/data/
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -50,7 +49,6 @@ func NewAnomalyRule(
|
||||
logger *slog.Logger,
|
||||
opts ...baserules.RuleOption,
|
||||
) (*AnomalyRule, error) {
|
||||
|
||||
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
|
||||
|
||||
opts = append(opts, baserules.WithLogger(logger))
|
||||
@@ -60,44 +58,44 @@ func NewAnomalyRule(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := AnomalyRule{
|
||||
r := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
switch strings.ToLower(p.RuleCondition.Seasonality) {
|
||||
case "hourly":
|
||||
t.seasonality = anomaly.SeasonalityHourly
|
||||
case "daily":
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
case "weekly":
|
||||
t.seasonality = anomaly.SeasonalityWeekly
|
||||
switch p.RuleCondition.Seasonality {
|
||||
case ruletypes.SeasonalityHourly:
|
||||
r.seasonality = anomaly.SeasonalityHourly
|
||||
case ruletypes.SeasonalityDaily:
|
||||
r.seasonality = anomaly.SeasonalityDaily
|
||||
case ruletypes.SeasonalityWeekly:
|
||||
r.seasonality = anomaly.SeasonalityWeekly
|
||||
default:
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
r.seasonality = anomaly.SeasonalityDaily
|
||||
}
|
||||
|
||||
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
|
||||
r.logger.Info("using seasonality", slog.String("rule.seasonality", r.seasonality.StringValue()))
|
||||
|
||||
if t.seasonality == anomaly.SeasonalityHourly {
|
||||
t.provider = anomaly.NewHourlyProvider(
|
||||
if r.seasonality == anomaly.SeasonalityHourly {
|
||||
r.provider = anomaly.NewHourlyProvider(
|
||||
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
|
||||
anomaly.WithLogger[*anomaly.HourlyProvider](r.logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityDaily {
|
||||
t.provider = anomaly.NewDailyProvider(
|
||||
} else if r.seasonality == anomaly.SeasonalityDaily {
|
||||
r.provider = anomaly.NewDailyProvider(
|
||||
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.DailyProvider](logger),
|
||||
anomaly.WithLogger[*anomaly.DailyProvider](r.logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityWeekly {
|
||||
t.provider = anomaly.NewWeeklyProvider(
|
||||
} else if r.seasonality == anomaly.SeasonalityWeekly {
|
||||
r.provider = anomaly.NewWeeklyProvider(
|
||||
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
|
||||
anomaly.WithLogger[*anomaly.WeeklyProvider](r.logger),
|
||||
)
|
||||
}
|
||||
|
||||
t.querier = querier
|
||||
t.version = p.Version
|
||||
t.logger = logger
|
||||
return &t, nil
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
@@ -105,8 +103,11 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
|
||||
r.logger.InfoContext(ctx, "prepare query range request", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()), slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()), slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()))
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
@@ -146,7 +147,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
}
|
||||
|
||||
if queryResult == nil {
|
||||
r.logger.WarnContext(ctx, "nil qb result", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()))
|
||||
r.logger.WarnContext(ctx, "nil qb result", slog.Int64("ts", ts.UnixMilli()))
|
||||
return ruletypes.Vector{}, nil
|
||||
}
|
||||
|
||||
@@ -157,7 +158,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
|
||||
return ruletypes.Vector{*missingDataAlert}, nil
|
||||
} else if !hasData {
|
||||
r.logger.WarnContext(ctx, "no anomaly result", slog.String("rule.id", r.ID()))
|
||||
r.logger.WarnContext(ctx, "no anomaly result")
|
||||
return ruletypes.Vector{}, nil
|
||||
}
|
||||
|
||||
@@ -165,7 +166,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
|
||||
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
|
||||
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
|
||||
r.logger.InfoContext(ctx, "anomaly scores", slog.String("rule.id", r.ID()), slog.String("anomaly.scores", string(scoresJSON)))
|
||||
r.logger.InfoContext(ctx, "anomaly scores", slog.String("anomaly.scores", string(scoresJSON)))
|
||||
|
||||
// Filter out new series if newGroupEvalDelay is configured
|
||||
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
|
||||
@@ -173,7 +174,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
|
||||
// In case of error we log the error and continue with the original series
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
|
||||
} else {
|
||||
seriesToProcess = filteredSeries
|
||||
}
|
||||
@@ -181,7 +182,11 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
|
||||
for _, series := range seriesToProcess {
|
||||
if !r.Condition().ShouldEval(series) {
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
|
||||
r.logger.InfoContext(
|
||||
ctx, "not enough data points to evaluate series, skipping",
|
||||
slog.Int("series.num_points", len(series.Values)),
|
||||
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
|
||||
)
|
||||
continue
|
||||
}
|
||||
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
|
||||
@@ -205,7 +210,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
var res ruletypes.Vector
|
||||
var err error
|
||||
|
||||
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
|
||||
r.logger.InfoContext(ctx, "running query")
|
||||
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
|
||||
|
||||
if err != nil {
|
||||
@@ -231,7 +236,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
value := valueFormatter.Format(smpl.V, r.Unit())
|
||||
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
|
||||
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
|
||||
r.logger.DebugContext(
|
||||
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
|
||||
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
|
||||
)
|
||||
|
||||
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
|
||||
// Inject some convenience variables that are easier to remember for users
|
||||
@@ -251,7 +259,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
result, err := tmpl.Expand()
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("<error expanding template: %s>", err)
|
||||
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -281,7 +289,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
resultFPs[h] = struct{}{}
|
||||
|
||||
if _, ok := alerts[h]; ok {
|
||||
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.String("rule.id", r.ID()), slog.Any("alert", alerts[h]))
|
||||
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.Any("alert", alerts[h]))
|
||||
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
|
||||
return 0, err
|
||||
}
|
||||
@@ -300,7 +308,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
|
||||
// alerts[h] is ready, add or update active list now
|
||||
for h, a := range alerts {
|
||||
// Check whether we already have alerting state for the identifying label set.
|
||||
@@ -327,7 +335,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
for fp, a := range r.Active {
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLabels)
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
|
||||
}
|
||||
if _, ok := resultFPs[fp]; !ok {
|
||||
// If the alert was previously firing, keep it around for a given
|
||||
@@ -382,7 +390,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
state = ruletypes.StateFiring
|
||||
}
|
||||
a.State = state
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
@@ -405,7 +413,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
itemsToAdd[idx] = item
|
||||
}
|
||||
|
||||
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
|
||||
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
|
||||
|
||||
return len(r.Active), nil
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: "daily",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
@@ -170,7 +170,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: "daily",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ReactChild, useCallback, useMemo } from 'react';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@@ -8,12 +8,10 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
@@ -25,6 +23,7 @@ import routes, {
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
@@ -57,7 +56,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
|
||||
const orgData = useMemo(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
return org[0];
|
||||
}
|
||||
return undefined;
|
||||
}, [org]);
|
||||
|
||||
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
|
||||
query: {
|
||||
@@ -75,214 +79,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return remainingUsers.length === 1;
|
||||
}, [usersData?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
|
||||
// User needs access to settings/billing to fix payment issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (hasWorkspaceIssue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
// if the current route is allowed to be overriden by org onboarding then only do the same
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname)
|
||||
) {
|
||||
history.push(ROUTES.ONBOARDING);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checkFirstTimeUser,
|
||||
isCloudUserVal,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUsers,
|
||||
orgPreferences,
|
||||
usersData,
|
||||
pathname,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.state,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceBlocked = useCallback((): void => {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname &&
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
history.push(ROUTES.WORKSPACE_LOCKED);
|
||||
}
|
||||
}, [isAdmin, pathname]);
|
||||
|
||||
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
|
||||
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
const { platform } = activeLicense;
|
||||
|
||||
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
|
||||
navigateToWorkSpaceAccessRestricted();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
activeLicense,
|
||||
navigateToWorkSpaceAccessRestricted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense) {
|
||||
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
|
||||
|
||||
if (
|
||||
shouldBlockWorkspace &&
|
||||
activeLicense?.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceBlocked();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.platform,
|
||||
navigateToWorkSpaceBlocked,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceSuspended = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
history.push(ROUTES.WORKSPACE_SUSPENDED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const shouldSuspendWorkspace =
|
||||
activeLicense.state === LicenseState.DEFAULTED;
|
||||
|
||||
if (
|
||||
shouldSuspendWorkspace &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceSuspended();
|
||||
}
|
||||
}
|
||||
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
setOrgData(org[0]);
|
||||
}
|
||||
}, [org]);
|
||||
|
||||
// if the feature flag is enabled and the current route is /get-started then redirect to /get-started-with-signoz-cloud
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
}
|
||||
}, [currentRoute, featureFlags]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect(() => {
|
||||
// if it is an old route navigate to the new route
|
||||
if (isOldRoute) {
|
||||
// this will be handled by the redirect component below
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route is public dashboard then don't redirect to login
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
|
||||
if (isPublicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
history.push(ROUTES.UN_AUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
// do nothing as the unauthenticated routes are LOGIN and SIGNUP and the LOGIN container takes care of routing to signup if
|
||||
// setup is not completed
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
|
||||
|
||||
// Handle old routes - redirect to new routes
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
return (
|
||||
@@ -296,7 +93,143 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: disabling this rule as there is no need to have div
|
||||
// Public dashboard - no redirect needed
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
if (isPublicDashboard) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
if (!isFetchingActiveLicense && activeLicense && isCloudPlatform) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
if (
|
||||
isWorkspaceAccessRestricted &&
|
||||
pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_ACCESS_RESTRICTED} />;
|
||||
}
|
||||
|
||||
// Check for workspace suspended (DEFAULTED)
|
||||
const shouldSuspendWorkspace = activeLicense.state === LicenseState.DEFAULTED;
|
||||
if (shouldSuspendWorkspace && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_SUSPENDED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for workspace blocked (trial expired)
|
||||
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_LOCKED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for onboarding redirect (cloud users, first user, onboarding not complete)
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (!hasWorkspaceIssue) {
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname) &&
|
||||
pathname !== ROUTES.ONBOARDING
|
||||
) {
|
||||
return <Redirect to={ROUTES.ONBOARDING} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
|
||||
}
|
||||
|
||||
// Main routing logic
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
// Save current path and redirect to login
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
// Non-private route, but user is logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
}
|
||||
// Non-private route, user not logged in - let login/signup pages handle it
|
||||
} else if (isLoggedInState) {
|
||||
// Unknown route, logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
} else {
|
||||
// Unknown route, not logged in
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import {
|
||||
@@ -22,19 +21,6 @@ import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
// Mock history module
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { pathname: '/', search: '', hash: '' },
|
||||
listen: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.Mock;
|
||||
|
||||
// Mock localStorage APIs
|
||||
const mockLocalStorage: Record<string, string> = {};
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
@@ -239,20 +225,18 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using these allows easier refactoring when switching from history.push to Redirect component
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
|
||||
async function assertRedirectsTo(targetRoute: string): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(targetRoute);
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(targetRoute);
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoRedirect(): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function assertDoesNotRedirectTo(targetRoute: string): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(targetRoute);
|
||||
function assertStaysOnRoute(expectedRoute: string): void {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
expectedRoute,
|
||||
);
|
||||
}
|
||||
|
||||
function assertRendersChildren(): void {
|
||||
@@ -350,7 +334,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
});
|
||||
|
||||
it('should render children for public dashboard route when logged in without redirecting', () => {
|
||||
@@ -362,7 +346,7 @@ describe('PrivateRoute', () => {
|
||||
assertRendersChildren();
|
||||
// Critical: without the isPublicDashboard early return, logged-in users
|
||||
// would be redirected to HOME due to the non-private route handling
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,7 +404,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should redirect to unauthorized when VIEWER tries to access admin-only route /alerts/new', async () => {
|
||||
@@ -529,7 +513,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.HOME);
|
||||
assertStaysOnRoute(ROUTES.SOMETHING_WENT_WRONG);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,7 +525,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should not redirect - login page handles its own routing
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits signup page', () => {
|
||||
@@ -550,7 +534,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute(ROUTES.SIGN_UP);
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits password reset page', () => {
|
||||
@@ -559,7 +543,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute(ROUTES.PASSWORD_RESET);
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits forgot password page', () => {
|
||||
@@ -568,7 +552,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertNoRedirect();
|
||||
assertStaysOnRoute(ROUTES.FORGOT_PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,7 +641,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Admin should be able to access settings even when workspace is blocked
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/billing when workspace is blocked', () => {
|
||||
@@ -673,7 +657,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/org-settings when workspace is blocked', () => {
|
||||
@@ -689,7 +673,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.ORG_SETTINGS);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/members when workspace is blocked', () => {
|
||||
@@ -705,7 +689,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.MEMBERS_SETTINGS);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/my-settings when workspace is blocked', () => {
|
||||
@@ -721,7 +705,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.MY_SETTINGS);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
|
||||
@@ -832,7 +816,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
|
||||
@@ -849,7 +833,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -919,7 +903,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace access restricted when license is terminated', () => {
|
||||
@@ -936,7 +920,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not redirect when license is ACTIVE', () => {
|
||||
@@ -953,7 +937,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not redirect when license is EVALUATING', () => {
|
||||
@@ -970,7 +954,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1006,7 +990,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
|
||||
@@ -1023,7 +1007,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1043,6 +1027,11 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
// Wait for the users query to complete and trigger re-render
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
@@ -1058,7 +1047,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when onboarding is already complete', async () => {
|
||||
@@ -1084,7 +1073,7 @@ describe('PrivateRoute', () => {
|
||||
|
||||
// Critical: if isOnboardingComplete check is broken (always false),
|
||||
// this test would fail because all other conditions for redirect ARE met
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding for non-cloud users', () => {
|
||||
@@ -1099,7 +1088,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-locked route', () => {
|
||||
@@ -1114,7 +1103,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-suspended route', () => {
|
||||
@@ -1129,7 +1118,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing billing', async () => {
|
||||
@@ -1156,7 +1145,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should NOT redirect to onboarding - user needs to access billing to fix payment
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing settings', async () => {
|
||||
@@ -1180,7 +1169,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is suspended (DEFAULTED)', async () => {
|
||||
@@ -1207,7 +1196,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_SUSPENDED, not ONBOARDING
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (TERMINATED)', async () => {
|
||||
@@ -1234,7 +1223,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_ACCESS_RESTRICTED, not ONBOARDING
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (EXPIRED)', async () => {
|
||||
@@ -1260,7 +1249,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1302,7 +1291,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
|
||||
@@ -1314,7 +1303,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
|
||||
@@ -1334,7 +1323,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1350,7 +1339,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('should not fetch users when org data is not available', () => {
|
||||
@@ -1393,9 +1382,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1436,22 +1423,40 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow all roles to access /services route', () => {
|
||||
const roles = [USER_ROLES.ADMIN, USER_ROLES.EDITOR, USER_ROLES.VIEWER];
|
||||
|
||||
roles.forEach((role) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: role as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
it('should allow ADMIN to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow VIEWER to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER from /onboarding route (admin only)', async () => {
|
||||
@@ -1481,7 +1486,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
@@ -1493,7 +1498,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface HostListPayload {
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
} | null;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
@@ -28,6 +30,8 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -54,13 +58,10 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,7 +91,7 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form
|
||||
|
||||
@@ -11,6 +11,16 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
@@ -92,10 +102,13 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -83,37 +83,6 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: var(--padding-1) var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 32px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useMemberRoleManager,
|
||||
} from 'hooks/member/useMemberRoleManager';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
@@ -61,10 +62,6 @@ function toSaveApiError(err: unknown): APIError {
|
||||
);
|
||||
}
|
||||
|
||||
function areSortedArraysEqual(a: string[], b: string[]): boolean {
|
||||
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
||||
}
|
||||
|
||||
export interface EditMemberDrawerProps {
|
||||
member: MemberRow | null;
|
||||
open: boolean;
|
||||
@@ -83,7 +80,7 @@ function EditMemberDrawer({
|
||||
const { user: currentUser } = useAppContext();
|
||||
|
||||
const [localDisplayName, setLocalDisplayName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
@@ -94,8 +91,11 @@ function EditMemberDrawer({
|
||||
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
const isDeleted = member?.status === MemberStatus.Deleted;
|
||||
const isSelf = !!member?.id && member.id === currentUser?.id;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const {
|
||||
data: fetchedUser,
|
||||
isLoading: isFetchingUser,
|
||||
@@ -115,32 +115,45 @@ function EditMemberDrawer({
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
|
||||
member?.id ?? '',
|
||||
open && !!member?.id,
|
||||
);
|
||||
const {
|
||||
fetchedRoleIds,
|
||||
isLoading: isMemberRolesLoading,
|
||||
applyDiff,
|
||||
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
|
||||
|
||||
const fetchedDisplayName =
|
||||
fetchedUser?.data?.displayName ?? member?.name ?? '';
|
||||
const fetchedUserId = fetchedUser?.data?.id;
|
||||
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedUserId) {
|
||||
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRoles(fetchedRoleIds);
|
||||
}, [fetchedRoleIds]);
|
||||
if (fetchedUserId) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [fetchedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!member?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
roleSessionRef.current = member.id;
|
||||
}
|
||||
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
fetchedUser != null &&
|
||||
(localDisplayName !== fetchedDisplayName ||
|
||||
!areSortedArraysEqual(localRoles, fetchedRoleIds));
|
||||
localRole !== (fetchedRoleIds[0] ?? ''));
|
||||
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
@@ -157,17 +170,10 @@ function EditMemberDrawer({
|
||||
onClose();
|
||||
},
|
||||
onError: (err): void => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
const prefix = isInvited
|
||||
? 'Failed to revoke invite'
|
||||
: 'Failed to delete member';
|
||||
toast.error(`${prefix}: ${errMessage}`, {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -224,7 +230,7 @@ function EditMemberDrawer({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const nameChanged = localDisplayName !== fetchedDisplayName;
|
||||
const rolesChanged = !areSortedArraysEqual(localRoles, fetchedRoleIds);
|
||||
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
|
||||
|
||||
const namePromise = nameChanged
|
||||
? isSelf
|
||||
@@ -237,7 +243,9 @@ function EditMemberDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
rolesChanged ? applyDiff(localRoles, availableRoles) : Promise.resolve([]),
|
||||
rolesChanged
|
||||
? applyDiff([localRole].filter(Boolean), availableRoles)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -255,7 +263,10 @@ function EditMemberDrawer({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: async (): Promise<void> => {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [
|
||||
@@ -303,7 +314,7 @@ function EditMemberDrawer({
|
||||
isDirty,
|
||||
isSelf,
|
||||
localDisplayName,
|
||||
localRoles,
|
||||
localRole,
|
||||
fetchedDisplayName,
|
||||
fetchedRoleIds,
|
||||
updateMyUser,
|
||||
@@ -343,15 +354,15 @@ function EditMemberDrawer({
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMsg as APIError);
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, isInvited, onClose]);
|
||||
}, [member, isInvited, onClose, showErrorModal]);
|
||||
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const handleCopyResetLink = useCallback((): void => {
|
||||
@@ -418,7 +429,7 @@ function EditMemberDrawer({
|
||||
}}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser}
|
||||
disabled={isRootUser || isDeleted}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -439,21 +450,22 @@ function EditMemberDrawer({
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
{isSelf || isRootUser ? (
|
||||
{isSelf || isRootUser || isDeleted ? (
|
||||
<Tooltip
|
||||
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
|
||||
title={
|
||||
isRootUser
|
||||
? ROOT_USER_TOOLTIP
|
||||
: isDeleted
|
||||
? undefined
|
||||
: 'You cannot modify your own role'
|
||||
}
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="edit-member-drawer__email-text">—</span>
|
||||
)}
|
||||
@@ -464,15 +476,14 @@ function EditMemberDrawer({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="member-role"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
value={localRoles}
|
||||
onChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -480,8 +491,8 @@ function EditMemberDrawer({
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="edit-member-drawer__role-select"
|
||||
placeholder="Select roles"
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -493,6 +504,10 @@ function EditMemberDrawer({
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : member?.status === MemberStatus.Deleted ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
@@ -531,55 +546,57 @@ function EditMemberDrawer({
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">{drawerBody}</div>
|
||||
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{!isDeleted && (
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink && 'Generating...'}
|
||||
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
|
||||
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink && 'Generating...'}
|
||||
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
|
||||
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
@@ -56,7 +55,6 @@ jest.mock('api/generated/services/users', () => ({
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
getResetPasswordToken: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -86,6 +84,16 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFetchedUser = {
|
||||
data: {
|
||||
id: 'user-1',
|
||||
@@ -149,6 +157,7 @@ function renderDrawer(
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
@@ -171,10 +180,6 @@ describe('EditMemberDrawer', () => {
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useDeleteUser as jest.Mock).mockReturnValue({
|
||||
mutate: mockDeleteMutate,
|
||||
isLoading: false,
|
||||
@@ -248,7 +253,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setRole when a new role is added', async () => {
|
||||
it('selecting a different role calls setRole with the new role name', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
@@ -277,32 +282,30 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('calls removeRole when an existing role is removed', async () => {
|
||||
it('does not call removeRole when the role is changed', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockRemove = jest.fn().mockResolvedValue({});
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockRemove,
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Wait for the signoz-admin tag to appear, then click its remove button
|
||||
const adminTag = await screen.findByTitle('signoz-admin');
|
||||
const removeBtn = adminTag.querySelector(
|
||||
'.ant-select-selection-item-remove',
|
||||
) as Element;
|
||||
await user.click(removeBtn);
|
||||
// Switch from signoz-admin to signoz-viewer using single-select
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemove).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-viewer' },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
@@ -467,7 +470,6 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
it('shows API error message when deleteUser fails for active member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -485,16 +487,20 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to delete member: Something went wrong on server',
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message when deleteUser fails for invited member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -512,9 +518,14 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to revoke invite: Something went wrong on server',
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,12 @@ import APIError from 'types/api/error';
|
||||
import './ErrorContent.styles.scss';
|
||||
|
||||
interface ErrorContentProps {
|
||||
error: APIError;
|
||||
error:
|
||||
| APIError
|
||||
| {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -20,7 +25,15 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
|
||||
errors: errorMessages,
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
} = error?.error?.error || {};
|
||||
} =
|
||||
error && 'error' in error
|
||||
? error?.error?.error || {}
|
||||
: {
|
||||
url: undefined,
|
||||
errors: [],
|
||||
code: error.code || 500,
|
||||
message: error.message || 'Something went wrong',
|
||||
};
|
||||
return (
|
||||
<section className="error-content">
|
||||
{/* Summary Header */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
@@ -40,6 +41,8 @@ function InviteMembersModal({
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
@@ -204,13 +207,11 @@ function InviteMembersModal({
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
const apiErr = err as APIError;
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(errorMessage, { richColors: true, position: 'top-right' });
|
||||
showErrorModal(err as APIError);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [rows, onComplete, resetAndClose, validateAllUsers]);
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
@@ -227,7 +228,7 @@ function InviteMembersModal({
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
@@ -329,6 +330,7 @@ function InviteMembersModal({
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -22,6 +21,16 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
@@ -34,6 +43,7 @@ const defaultProps = {
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
@@ -154,9 +164,10 @@ describe('InviteMembersModal', () => {
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: single@signoz.io'),
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -171,18 +182,16 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockInviteUsers.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: alice@signoz.io'),
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -201,18 +210,17 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -227,10 +235,7 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Internal server error',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ function MembersTable({
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => {
|
||||
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
|
||||
const isClickable = !!onRowClick;
|
||||
return {
|
||||
onClick: (): void => {
|
||||
if (isClickable) {
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('MembersTable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
|
||||
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const deletedMember: MemberRow = {
|
||||
@@ -108,7 +108,7 @@ describe('MembersTable', () => {
|
||||
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Dave Deleted'));
|
||||
expect(onRowClick).not.toHaveBeenCalledWith(
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-del' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,3 +88,13 @@
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.roles-single-select {
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ interface BaseProps {
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
onChange?: (role: string | undefined) => void;
|
||||
allowClear?: boolean;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
@@ -154,14 +155,15 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
const { value, onChange, allowClear = true } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
allowClear={allowClear}
|
||||
className={cx('roles-single-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AxiosError } from 'axios';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import KeyCreatedPhase from './KeyCreatedPhase';
|
||||
import KeyFormPhase from './KeyFormPhase';
|
||||
@@ -27,6 +29,7 @@ import './AddKeyModal.styles.scss';
|
||||
|
||||
function AddKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.ADD_KEY,
|
||||
@@ -81,11 +84,11 @@ function AddKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -151,7 +154,7 @@ function AddKeyModal(): JSX.Element {
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
{phase === Phase.FORM && (
|
||||
<KeyFormPhase
|
||||
|
||||
@@ -16,9 +16,12 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function DeleteAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
@@ -45,11 +48,11 @@ function DeleteAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to delete service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -79,7 +82,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
|
||||
@@ -17,7 +17,9 @@ import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { RevokeKeyContent } from '../RevokeKeyModal';
|
||||
import EditKeyForm from './EditKeyForm';
|
||||
@@ -41,6 +43,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
const open = !!editKeyId && !!selectedAccountId;
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -78,11 +81,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -102,12 +105,13 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -160,7 +164,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
|
||||
@@ -16,8 +16,8 @@ interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
localRole: string;
|
||||
onRoleChange: (v: string | undefined) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
@@ -31,8 +31,8 @@ function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
localRole,
|
||||
onRoleChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
@@ -96,15 +96,10 @@ function OverviewTab({
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
@@ -114,15 +109,14 @@ function OverviewTab({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
value={localRole}
|
||||
onChange={onRoleChange}
|
||||
placeholder="Select role"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
isRevoking: boolean;
|
||||
@@ -56,6 +58,7 @@ export function RevokeKeyContent({
|
||||
|
||||
function RevokeKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [revokeKeyId, setRevokeKeyId] = useQueryState(
|
||||
SA_QUERY_PARAMS.REVOKE_KEY,
|
||||
@@ -83,11 +86,11 @@ function RevokeKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -115,7 +118,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
@@ -8,7 +8,9 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
getListServiceAccountsQueryKey,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
@@ -23,7 +25,10 @@ import {
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -32,7 +37,7 @@ import {
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { retryOn429, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
@@ -49,6 +54,13 @@ export interface ServiceAccountDrawerProps {
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
function toSaveApiError(err: unknown): APIError {
|
||||
return (
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
onSuccess,
|
||||
@@ -80,7 +92,7 @@ function ServiceAccountDrawer({
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
@@ -103,21 +115,35 @@ function ServiceAccountDrawer({
|
||||
[accountData],
|
||||
);
|
||||
|
||||
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
|
||||
selectedAccountId ?? '',
|
||||
);
|
||||
const {
|
||||
currentRoles,
|
||||
isLoading: isRolesLoading,
|
||||
applyDiff,
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '');
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
setKeysPage(1);
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
|
||||
}, [currentRoles]);
|
||||
if (account?.id) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [account?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
roleSessionRef.current = account.id;
|
||||
}
|
||||
}, [account?.id, currentRoles, isRolesLoading]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
@@ -125,8 +151,7 @@ function ServiceAccountDrawer({
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
|
||||
localRole !== (currentRoles[0]?.id ?? ''));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
@@ -154,12 +179,26 @@ function ServiceAccountDrawer({
|
||||
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
mutation: {
|
||||
retry: retryOn429,
|
||||
},
|
||||
});
|
||||
|
||||
const toSaveApiError = useCallback(
|
||||
(err: unknown): APIError =>
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
|
||||
[],
|
||||
const executeRolesOperation = useCallback(
|
||||
async (accountId: string): Promise<RoleUpdateFailure[]> => {
|
||||
if (localRole === '' && currentRoles[0]?.id) {
|
||||
await deleteRole({
|
||||
pathParams: { id: accountId, rid: currentRoles[0].id },
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
getGetServiceAccountRolesQueryKey({ id: accountId }),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return applyDiff([localRole].filter(Boolean), availableRoles);
|
||||
},
|
||||
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
|
||||
);
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
@@ -181,14 +220,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
@@ -211,26 +243,39 @@ function ServiceAccountDrawer({
|
||||
);
|
||||
}
|
||||
},
|
||||
[toSaveApiError],
|
||||
[],
|
||||
);
|
||||
|
||||
const clearRoleErrors = useCallback((): void => {
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const failuresToSaveErrors = useCallback(
|
||||
(failures: RoleUpdateFailure[]): SaveError[] =>
|
||||
failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
}),
|
||||
[makeRoleRetry],
|
||||
);
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
const failures = await executeRolesOperation(selectedAccountId ?? '');
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
const roleErrors = failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
});
|
||||
return [...rest, ...roleErrors];
|
||||
return [...rest, ...failuresToSaveErrors(failures)];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -240,7 +285,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
@@ -259,7 +304,7 @@ function ServiceAccountDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
applyDiff(localRoles, availableRoles),
|
||||
executeRolesOperation(account.id),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -279,14 +324,7 @@ function ServiceAccountDrawer({
|
||||
onRetry: retryRolesUpdate,
|
||||
});
|
||||
} else {
|
||||
for (const failure of rolesResult.value) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
errors.push(...failuresToSaveErrors(rolesResult.value));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -308,17 +346,14 @@ function ServiceAccountDrawer({
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRoles,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
applyDiff,
|
||||
executeRolesOperation,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
failuresToSaveErrors,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
@@ -410,8 +445,11 @@ function ServiceAccountDrawer({
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
localRole={localRole}
|
||||
onRoleChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
|
||||
@@ -139,20 +139,20 @@ describe('ServiceAccountDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
|
||||
const roleSpy = jest.fn();
|
||||
const deleteSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
}),
|
||||
);
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role update failure shows SaveErrorItem with the role name context', async () => {
|
||||
it('role add failure shows SaveErrorItem with the role name context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
@@ -390,6 +390,42 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add retries on 429 then succeeds without showing an error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let roleAddCallCount = 0;
|
||||
|
||||
// First call → 429, second call → 200
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
|
||||
roleAddCallCount += 1;
|
||||
if (roleAddCallCount === 1) {
|
||||
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
// Retried after 429 — at least 2 calls, no error shown
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(roleAddCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(screen.queryByText(/role assign failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 60;
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_MIN_WIDTH = 200;
|
||||
|
||||
export default function ChartWrapper({
|
||||
|
||||
@@ -838,22 +838,20 @@ function FormAlertRules({
|
||||
>
|
||||
<div className="overview-header">
|
||||
<div className="alert-type-container">
|
||||
{isNewRule && (
|
||||
<Typography.Title level={5} className="alert-type-title">
|
||||
<BellDot size={14} />
|
||||
<Typography.Title level={5} className="alert-type-title">
|
||||
<BellDot size={14} />
|
||||
|
||||
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
'Anomaly Detection Alert'}
|
||||
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
'Metrics Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
|
||||
'Logs Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
|
||||
'Traces Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
|
||||
'Exceptions Based Alert'}
|
||||
</Typography.Title>
|
||||
)}
|
||||
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
'Anomaly Detection Alert'}
|
||||
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
'Metrics Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
|
||||
'Logs Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
|
||||
'Traces Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
|
||||
'Exceptions Based Alert'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -264,20 +264,22 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<PersistedAnnouncementBanner
|
||||
type="info"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<strong>API keys</strong> have been deprecated in favour of{' '}
|
||||
<strong>Service accounts</strong>. The existing API Keys have been migrated
|
||||
to service accounts.
|
||||
</>
|
||||
</PersistedAnnouncementBanner>
|
||||
{user?.role === USER_ROLES.ADMIN && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="info"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<strong>API keys</strong> have been deprecated in favour of{' '}
|
||||
<strong>Service accounts</strong>. The existing API Keys have been
|
||||
migrated to service accounts.
|
||||
</>
|
||||
</PersistedAnnouncementBanner>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
|
||||
@@ -1,41 +1,52 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQuery } from 'react-query';
|
||||
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
|
||||
import {
|
||||
getHostLists,
|
||||
HostListPayload,
|
||||
HostListResponse,
|
||||
} from 'api/infraMonitoring/getHostLists';
|
||||
import HostMetricDetail from 'components/HostMetricsDetail';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFiltersHosts,
|
||||
useInfraMonitoringOrderByHosts,
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import HostsListControls from './HostsListControls';
|
||||
import HostsListTable from './HostsListTable';
|
||||
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
|
||||
|
||||
import './InfraMonitoring.styles.scss';
|
||||
function HostsList(): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const defaultFilters: TagFilter = { items: [], op: 'and' };
|
||||
const baseQuery = getHostListsQuery();
|
||||
|
||||
function HostsList(): JSX.Element {
|
||||
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
|
||||
const [filters, setFilters] = useInfraMonitoringFiltersHosts();
|
||||
const [orderBy, setOrderBy] = useInfraMonitoringOrderByHosts();
|
||||
@@ -62,57 +73,49 @@ function HostsList(): JSX.Element {
|
||||
|
||||
const { pageSize, setPageSize } = usePageSize('hosts');
|
||||
|
||||
const query = useMemo(() => {
|
||||
const baseQuery = getHostListsQuery();
|
||||
return {
|
||||
...baseQuery,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
filters,
|
||||
start: Math.floor(minTime / 1000000),
|
||||
end: Math.floor(maxTime / 1000000),
|
||||
orderBy,
|
||||
};
|
||||
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
if (selectedHostName) {
|
||||
return [
|
||||
'hostList',
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
REACT_QUERY_KEY.GET_HOST_LIST,
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(filters),
|
||||
JSON.stringify(orderBy),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'hostList',
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(filters),
|
||||
JSON.stringify(orderBy),
|
||||
String(minTime),
|
||||
String(maxTime),
|
||||
];
|
||||
}, [
|
||||
pageSize,
|
||||
currentPage,
|
||||
filters,
|
||||
orderBy,
|
||||
selectedHostName,
|
||||
minTime,
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useGetHostList(
|
||||
query as HostListPayload,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!query,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
),
|
||||
[pageSize, currentPage, filters, orderBy, selectedTime],
|
||||
);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery<
|
||||
SuccessResponse<HostListResponse> | ErrorResponse,
|
||||
Error
|
||||
>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
const payload: HostListPayload = {
|
||||
...baseQuery,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
filters: filters ?? defaultFilters,
|
||||
orderBy,
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
|
||||
return getHostLists(payload, signal);
|
||||
},
|
||||
enabled: true,
|
||||
keepPreviousData: true,
|
||||
refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
});
|
||||
|
||||
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
|
||||
data,
|
||||
]);
|
||||
@@ -227,7 +230,7 @@ function HostsList(): JSX.Element {
|
||||
isError={isError}
|
||||
tableData={data}
|
||||
hostMetricsData={hostMetricsData}
|
||||
filters={filters || { items: [], op: 'AND' }}
|
||||
filters={filters ?? defaultFilters}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
onHostClick={handleHostClick}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -26,9 +27,40 @@ import {
|
||||
function EmptyOrLoadingView(
|
||||
viewState: EmptyOrLoadingViewProps,
|
||||
): React.ReactNode {
|
||||
const { isError, errorMessage } = viewState;
|
||||
if (isError) {
|
||||
return <Typography>{errorMessage || 'Something went wrong'}</Typography>;
|
||||
if (viewState.showTableLoadingState) {
|
||||
return (
|
||||
<div className="hosts-list-loading-state">
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { isError, data } = viewState;
|
||||
if (isError || data?.error || (data?.statusCode || 0) >= 300) {
|
||||
return (
|
||||
<ErrorContent
|
||||
error={{
|
||||
code: data?.statusCode || 500,
|
||||
message: data?.error || 'Something went wrong',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewState.showHostsEmptyState) {
|
||||
return (
|
||||
@@ -76,30 +108,6 @@ function EmptyOrLoadingView(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (viewState.showTableLoadingState) {
|
||||
return (
|
||||
<div className="hosts-list-loading-state">
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
<Skeleton.Input
|
||||
className="hosts-list-loading-state-item"
|
||||
size="large"
|
||||
block
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -190,7 +198,8 @@ export default function HostsListTable({
|
||||
!isLoading &&
|
||||
formattedHostMetricsData.length === 0 &&
|
||||
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
|
||||
!filters.items.length;
|
||||
!filters.items.length &&
|
||||
!endTimeBeforeRetention;
|
||||
|
||||
const showEndTimeBeforeRetentionMessage =
|
||||
!isFetching &&
|
||||
@@ -211,7 +220,7 @@ export default function HostsListTable({
|
||||
|
||||
const emptyOrLoadingView = EmptyOrLoadingView({
|
||||
isError,
|
||||
errorMessage: data?.error ?? '',
|
||||
data,
|
||||
showHostsEmptyState,
|
||||
sentAnyHostMetricsData,
|
||||
isSendingIncorrectK8SAgentMetrics,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
|
||||
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import * as timezoneHooks from 'providers/Timezone';
|
||||
@@ -19,6 +19,10 @@ jest.mock('lib/getMinMax', () => ({
|
||||
maxTime: 1713738000000,
|
||||
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
|
||||
})),
|
||||
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
|
||||
minTime: 1713734400000000000,
|
||||
maxTime: 1713738000000000000,
|
||||
}),
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
@@ -41,7 +45,13 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
@@ -80,27 +90,40 @@ jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
|
||||
offset: 0,
|
||||
},
|
||||
} as any);
|
||||
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
records: [
|
||||
{
|
||||
hostName: 'test-host',
|
||||
active: true,
|
||||
cpu: 0.75,
|
||||
memory: 0.65,
|
||||
wait: 0.03,
|
||||
},
|
||||
],
|
||||
isSendingK8SAgentMetrics: false,
|
||||
sentAnyHostMetricsData: true,
|
||||
},
|
||||
|
||||
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: [
|
||||
{
|
||||
hostName: 'test-host',
|
||||
active: true,
|
||||
os: 'linux',
|
||||
cpu: 0.75,
|
||||
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
memory: 0.65,
|
||||
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
wait: 0.03,
|
||||
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
load15: 0.5,
|
||||
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
},
|
||||
],
|
||||
groups: null,
|
||||
total: 1,
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
endTimeBeforeRetention: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
params: {} as any,
|
||||
});
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
@@ -128,22 +151,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
|
||||
|
||||
describe('HostsList', () => {
|
||||
it('renders hosts list table', () => {
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<HostsList />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders filters', () => {
|
||||
it('renders hosts list table', async () => {
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -155,6 +167,25 @@ describe('HostsList', () => {
|
||||
</QueryClientProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(container.querySelector('.filters')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders filters', async () => {
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<HostsList />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.filters')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
|
||||
import { TableColumnType as ColumnType } from 'antd';
|
||||
import {
|
||||
Progress,
|
||||
TableColumnType as ColumnType,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import {
|
||||
HostData,
|
||||
@@ -13,8 +18,6 @@ import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
} from 'components/QuickFilters/types';
|
||||
import TabLabel from 'components/TabLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -22,9 +25,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderBySchemaType } from '../InfraMonitoringK8s/schemas';
|
||||
import HostsList from './HostsList';
|
||||
|
||||
import './InfraMonitoring.styles.scss';
|
||||
|
||||
export interface HostRowData {
|
||||
key?: string;
|
||||
@@ -112,7 +112,10 @@ export interface HostsListTableProps {
|
||||
|
||||
export interface EmptyOrLoadingViewProps {
|
||||
isError: boolean;
|
||||
errorMessage: string;
|
||||
data:
|
||||
| ErrorResponse<string>
|
||||
| SuccessResponse<HostListResponse, unknown>
|
||||
| undefined;
|
||||
showHostsEmptyState: boolean;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingIncorrectK8SAgentMetrics: boolean;
|
||||
@@ -141,14 +144,6 @@ function mapOrderByToSortOrder(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const getTabsItems = (): TabsProps['items'] => [
|
||||
{
|
||||
label: <TabLabel label="List View" isDisabled={false} tooltipText="" />,
|
||||
key: PANEL_TYPES.LIST,
|
||||
children: <HostsList />,
|
||||
},
|
||||
];
|
||||
|
||||
export const getHostsListColumns = (
|
||||
orderBy: OrderBySchemaType,
|
||||
): ColumnType<HostRowData>[] => [
|
||||
|
||||
@@ -51,6 +51,8 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
if (filterMode === FilterMode.Invited) {
|
||||
result = result.filter((m) => m.status === MemberStatus.Invited);
|
||||
} else if (filterMode === FilterMode.Deleted) {
|
||||
result = result.filter((m) => m.status === MemberStatus.Deleted);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
@@ -89,6 +91,9 @@ function MembersSettings(): JSX.Element {
|
||||
const pendingCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Invited,
|
||||
).length;
|
||||
const deletedCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Deleted,
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
@@ -118,12 +123,27 @@ function MembersSettings(): JSX.Element {
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Deleted,
|
||||
label: (
|
||||
<div className="members-filter-option">
|
||||
<span>Deleted ⎯ {deletedCount}</span>
|
||||
{filterMode === FilterMode.Deleted && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterLabel =
|
||||
filterMode === FilterMode.All
|
||||
? `All members ⎯ ${totalCount}`
|
||||
: `Pending invites ⎯ ${pendingCount}`;
|
||||
: filterMode === FilterMode.Invited
|
||||
? `Pending invites ⎯ ${pendingCount}`
|
||||
: `Deleted ⎯ ${deletedCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
|
||||
@@ -117,14 +117,14 @@ describe('MembersSettings (integration)', () => {
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('does not open EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Member Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum MemberStatus {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.display-name-form {
|
||||
.form-field {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--destructive);
|
||||
margin-top: var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import DisplayName from '../index';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ORG_ME_ENDPOINT = '*/api/v2/orgs/me';
|
||||
|
||||
const defaultProps = { index: 0, id: 'does-not-matter-id' };
|
||||
|
||||
describe('DisplayName', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders form pre-filled with org displayName from context', async () => {
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
expect(input).toHaveValue('Pentagon');
|
||||
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit and calls PUT when display name is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(rest.put(ORG_ME_ENDPOINT, (_, res, ctx) => res(ctx.status(200))));
|
||||
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Org Name');
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /submit/i });
|
||||
expect(submitBtn).toBeEnabled();
|
||||
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error when display name is cleared and submitted', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.clear(input);
|
||||
|
||||
const form = input.closest('form') as HTMLFormElement;
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/missing display name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { Button, Input } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
|
||||
import {
|
||||
useGetMyOrganization,
|
||||
useUpdateMyOrganization,
|
||||
} from 'api/generated/services/orgs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
|
||||
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const orgName = Form.useWatch('displayName', form);
|
||||
import './DisplayName.styles.scss';
|
||||
|
||||
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
const { org, updateOrg } = useAppContext();
|
||||
const { displayName } = (org || [])[index];
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { org, updateOrg, user } = useAppContext();
|
||||
const currentOrg = (org || [])[index];
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
|
||||
const { data: orgData } = useGetMyOrganization({
|
||||
query: {
|
||||
enabled: isAdmin && !currentOrg?.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const displayName =
|
||||
currentOrg?.displayName ?? orgData?.data?.displayName ?? '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { displayName },
|
||||
});
|
||||
|
||||
const orgName = watch('displayName');
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName && !getValues('displayName')) {
|
||||
setValue('displayName', displayName);
|
||||
}
|
||||
}, [displayName, getValues, setValue]);
|
||||
|
||||
const {
|
||||
mutateAsync: updateMyOrganization,
|
||||
@@ -30,20 +66,16 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
updateOrg(orgId, data.displayName ?? '');
|
||||
},
|
||||
onError: (error) => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
toast.error(
|
||||
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
|
||||
{ richColors: true, position: 'top-right' },
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues): Promise<void> => {
|
||||
const { displayName } = values;
|
||||
await updateMyOrganization({ data: { id: orgId, displayName } });
|
||||
const { displayName: name } = values;
|
||||
await updateMyOrganization({ data: { id: orgId, displayName: name } });
|
||||
};
|
||||
|
||||
if (!org) {
|
||||
@@ -53,21 +85,34 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const isDisabled = isLoading || orgName === displayName || !orgName;
|
||||
|
||||
return (
|
||||
<Form
|
||||
initialValues={{ displayName }}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
<form
|
||||
className="display-name-form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="displayName"
|
||||
label="Display name"
|
||||
rules={[{ required: true, message: requireErrorMessage('Display name') }]}
|
||||
>
|
||||
<Input size="large" placeholder={t('signoz')} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<div className="form-field">
|
||||
<label htmlFor="displayName">Display name</label>
|
||||
<Controller
|
||||
name="displayName"
|
||||
control={control}
|
||||
rules={{ required: requireErrorMessage('Display name') }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
id="displayName"
|
||||
size="large"
|
||||
placeholder={t('signoz')}
|
||||
status={fieldState.error ? 'error' : ''}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<div className="field-error">{fieldState.error.message}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
@@ -76,8 +121,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,11 @@ function DateTimeSelection({
|
||||
const invalidateQueries = useGlobalTimeQueryInvalidate();
|
||||
const onRefreshHandler = (): void => {
|
||||
invalidateQueries();
|
||||
onSelectHandler(selectedTime);
|
||||
onSelectHandler(
|
||||
isModalTimeSelection && modalSelectedInterval
|
||||
? modalSelectedInterval
|
||||
: selectedTime,
|
||||
);
|
||||
onLastRefreshHandler();
|
||||
};
|
||||
const handleReset = useCallback(() => {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getHostLists,
|
||||
HostListPayload,
|
||||
HostListResponse,
|
||||
} from 'api/infraMonitoring/getHostLists';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
type UseGetHostList = (
|
||||
requestData: HostListPayload,
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponse<HostListResponse> | ErrorResponse,
|
||||
Error
|
||||
>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponse<HostListResponse> | ErrorResponse, Error>;
|
||||
|
||||
export const useGetHostList: UseGetHostList = (
|
||||
requestData,
|
||||
options,
|
||||
headers,
|
||||
) => {
|
||||
const queryKey = useMemo(() => {
|
||||
if (options?.queryKey && Array.isArray(options.queryKey)) {
|
||||
return [...options.queryKey];
|
||||
}
|
||||
|
||||
if (options?.queryKey && typeof options.queryKey === 'string') {
|
||||
return options.queryKey;
|
||||
}
|
||||
|
||||
return [REACT_QUERY_KEY.GET_HOST_LIST, requestData];
|
||||
}, [options?.queryKey, requestData]);
|
||||
|
||||
return useQuery<SuccessResponse<HostListResponse> | ErrorResponse, Error>({
|
||||
queryFn: ({ signal }) => getHostLists(requestData, signal, headers),
|
||||
...options,
|
||||
queryKey,
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
} from 'api/generated/services/users';
|
||||
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
export interface MemberRoleUpdateFailure {
|
||||
roleName: string;
|
||||
@@ -42,8 +39,9 @@ export function useMemberRoleManager(
|
||||
[currentUserRoles],
|
||||
);
|
||||
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID();
|
||||
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID();
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
@@ -53,25 +51,12 @@ export function useMemberRoleManager(
|
||||
const currentRoleIdSet = new Set(fetchedRoleIds);
|
||||
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
|
||||
|
||||
const toRemove = currentUserRoles.filter((ur) => {
|
||||
const id = ur.role?.id ?? ur.roleId;
|
||||
return id && !desiredRoleIdSet.has(id);
|
||||
});
|
||||
const toAdd = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
|
||||
);
|
||||
|
||||
/// TODO: re-enable deletes once BE for this is streamlined
|
||||
const allOps = [
|
||||
...toRemove.map((ur) => ({
|
||||
roleName: ur.role?.name ?? 'unknown',
|
||||
run: (): ReturnType<typeof removeRole> =>
|
||||
removeRole({
|
||||
pathParams: {
|
||||
id: userId,
|
||||
roleId: ur.role?.id ?? ur.roleId ?? '',
|
||||
},
|
||||
}),
|
||||
})),
|
||||
...toAdd.map((role) => ({
|
||||
roleName: role.name ?? 'unknown',
|
||||
run: (): ReturnType<typeof setRole> =>
|
||||
@@ -94,7 +79,7 @@ export function useMemberRoleManager(
|
||||
|
||||
return failures;
|
||||
},
|
||||
[userId, fetchedRoleIds, currentUserRoles, setRole, removeRole],
|
||||
[userId, fetchedRoleIds, setRole],
|
||||
);
|
||||
|
||||
return { fetchedRoleIds, isLoading, applyDiff };
|
||||
|
||||
@@ -3,10 +3,15 @@ import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
Fulfilled = 'fulfilled',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
export interface RoleUpdateFailure {
|
||||
roleName: string;
|
||||
@@ -35,8 +40,9 @@ export function useServiceAccountRoleManager(
|
||||
]);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
@@ -62,32 +68,29 @@ export function useServiceAccountRoleManager(
|
||||
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const removedRoles = currentRoles.filter(
|
||||
(r) => r.id && !desiredRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
// TODO: re-enable deletes once BE for this is streamlined
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof createRole> =>
|
||||
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof deleteRole> =>
|
||||
deleteRole({ pathParams: { id: accountId, rid: role.id } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
await invalidateRoles();
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === PromiseStatus.Fulfilled,
|
||||
).length;
|
||||
if (successCount > 0) {
|
||||
await invalidateRoles();
|
||||
}
|
||||
|
||||
const failures: RoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
if (result.status === PromiseStatus.Rejected) {
|
||||
const { role, run } = allOperations[index];
|
||||
failures.push({
|
||||
roleName: role.name ?? 'unknown',
|
||||
@@ -102,7 +105,7 @@ export function useServiceAccountRoleManager(
|
||||
|
||||
return failures;
|
||||
},
|
||||
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
|
||||
[accountId, currentRoles, createRole, invalidateRoles],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.uplot-tooltip-container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-header-container {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.uplot-tooltip-list {
|
||||
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
|
||||
// horizontal offset when the scroller has padding or transform applied.
|
||||
div[data-viewport-type='element'] {
|
||||
left: 0;
|
||||
padding: 4px 8px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.uplot-tooltip-container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 0.5rem 0.5rem 1rem;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uplot-tooltip-list-container {
|
||||
overflow-y: auto;
|
||||
max-height: 330px;
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.uplot-tooltip-item-marker {
|
||||
border-radius: 50%;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uplot-tooltip-item-content {
|
||||
white-space: wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipItem from './components/TooltipItem/TooltipItem';
|
||||
|
||||
import './Tooltip.styles.scss';
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
const TOOLTIP_LIST_MAX_HEIGHT = 330;
|
||||
// Fallback per-item height used for the initial size estimate before
|
||||
// Virtuoso reports the real total height via totalListHeightChanged.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const TOOLTIP_LIST_PADDING = 10;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
export default function Tooltip({
|
||||
uPlotInstance,
|
||||
@@ -21,27 +23,26 @@ export default function Tooltip({
|
||||
showTooltipHeader = true,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
const tooltipContent = content ?? [];
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
const resolvedTimezone = useMemo(() => {
|
||||
if (!timezone) {
|
||||
return userTimezone.value;
|
||||
}
|
||||
return timezone.value;
|
||||
}, [timezone, userTimezone]);
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const data = uPlotInstance.data;
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
@@ -51,60 +52,68 @@ export default function Tooltip({
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
const virtuosoStyle = useMemo(() => {
|
||||
return {
|
||||
height:
|
||||
listHeight > 0
|
||||
? Math.min(listHeight + TOOLTIP_LIST_PADDING, TOOLTIP_LIST_MAX_HEIGHT)
|
||||
: Math.min(
|
||||
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
|
||||
TOOLTIP_LIST_MAX_HEIGHT,
|
||||
),
|
||||
width: '100%',
|
||||
};
|
||||
}, [listHeight, tooltipContent.length]);
|
||||
const activeItem = useMemo(
|
||||
() => tooltipContent.find((item) => item.isActive) ?? null,
|
||||
[tooltipContent],
|
||||
);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on the first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const virtuosoHeight = useMemo(() => {
|
||||
return totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
|
||||
}, [totalListHeight, tooltipContent.length]);
|
||||
|
||||
const showHeader = showTooltipHeader || activeItem != null;
|
||||
// With a single series the active item is fully represented in the header —
|
||||
// hide the divider and list to avoid showing a duplicate row.
|
||||
const showList = tooltipContent.length > 1;
|
||||
const showDivider = showList && showHeader;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'uplot-tooltip-container',
|
||||
isDarkMode ? 'darkMode' : 'lightMode',
|
||||
)}
|
||||
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showTooltipHeader && (
|
||||
<div className="uplot-tooltip-header" data-testid="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
{showHeader && (
|
||||
<div className={Styles.uplotTooltipHeaderContainer}>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div
|
||||
className={Styles.uplotTooltipHeader}
|
||||
data-testid="uplot-tooltip-header"
|
||||
>
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="uplot-tooltip-list-container">
|
||||
{tooltipContent.length > 0 ? (
|
||||
<Virtuoso
|
||||
className="uplot-tooltip-list"
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={virtuosoStyle}
|
||||
totalListHeightChanged={setListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<div className="uplot-tooltip-item" data-testid="uplot-tooltip-item">
|
||||
<div
|
||||
className="uplot-tooltip-item-marker"
|
||||
style={{ borderColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid="uplot-tooltip-item-marker"
|
||||
/>
|
||||
<div
|
||||
className="uplot-tooltip-item-content"
|
||||
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
|
||||
data-testid="uplot-tooltip-item-content"
|
||||
>
|
||||
{item.label}: {item.tooltipValue}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showDivider && <span className={Styles.uplotTooltipDivider} />}
|
||||
|
||||
{showList && (
|
||||
<Virtuoso
|
||||
className={Styles.uplotTooltipList}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={{ height: virtuosoHeight, width: '100%' }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,46 +133,30 @@ describe('Tooltip', () => {
|
||||
expect(screen.queryByText(unexpectedTitle)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders lightMode class when dark mode is disabled', () => {
|
||||
it('renders single active item in header only, without a list', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
const container = screen.getByTestId('uplot-tooltip-container');
|
||||
|
||||
expect(container).toHaveClass('lightMode');
|
||||
expect(container).not.toHaveClass('darkMode');
|
||||
});
|
||||
|
||||
it('renders darkMode class when dark mode is enabled', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
mockUseIsDarkMode.mockReturnValue(true);
|
||||
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
const container = screen.getByTestId('uplot-tooltip-container');
|
||||
|
||||
expect(container).toHaveClass('darkMode');
|
||||
expect(container).not.toHaveClass('lightMode');
|
||||
});
|
||||
|
||||
it('renders tooltip items when content is provided', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
const content = [createTooltipContent()];
|
||||
const content = [createTooltipContent({ isActive: true })];
|
||||
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
const list = screen.queryByTestId('uplot-tooltip-list');
|
||||
// Active item is shown in the header, not duplicated in a list
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
expect(screen.getByTestId('uplot-tooltip-pinned')).toBeInTheDocument();
|
||||
const pinnedContent = screen.getByTestId('uplot-tooltip-pinned-content');
|
||||
expect(pinnedContent).toHaveTextContent('Series A');
|
||||
expect(pinnedContent).toHaveTextContent('10');
|
||||
});
|
||||
|
||||
expect(list).not.toBeNull();
|
||||
it('renders list when multiple series are present', () => {
|
||||
const uPlotInstance = createUPlotInstance(null);
|
||||
const content = [
|
||||
createTooltipContent({ isActive: true }),
|
||||
createTooltipContent({ label: 'Series B', isActive: false }),
|
||||
];
|
||||
|
||||
const marker = screen.getByTestId('uplot-tooltip-item-marker');
|
||||
const itemContent = screen.getByTestId('uplot-tooltip-item-content');
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
expect(marker).toHaveStyle({ borderColor: '#ff0000' });
|
||||
expect(itemContent).toHaveStyle({ color: '#ff0000', fontWeight: '700' });
|
||||
expect(itemContent).toHaveTextContent('Series A: 10');
|
||||
expect(screen.getByTestId('uplot-tooltip-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render tooltip list when content is empty', () => {
|
||||
@@ -192,7 +176,7 @@ describe('Tooltip', () => {
|
||||
renderTooltip({ uPlotInstance, content });
|
||||
|
||||
const list = screen.getByTestId('uplot-tooltip-list');
|
||||
expect(list).toHaveStyle({ height: '210px' });
|
||||
expect(list).toHaveStyle({ height: '200px' });
|
||||
});
|
||||
|
||||
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
|
||||
];
|
||||
}
|
||||
|
||||
it('builds tooltip content with active series first', () => {
|
||||
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
|
||||
const data: AlignedData = [[0], [10], [20], [30]];
|
||||
const series = createSeriesConfig();
|
||||
const dataIndexes = [null, 0, 0, 0];
|
||||
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Active (series index 2) should come first
|
||||
// Series are returned in series-index order (A=index 1 before B=index 2)
|
||||
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'A',
|
||||
value: 10,
|
||||
tooltipValue: 'formatted-10',
|
||||
color: '#ff0000',
|
||||
isActive: false,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips series with null data index or non-finite values', () => {
|
||||
@@ -273,5 +273,31 @@ describe('Tooltip utils', () => {
|
||||
expect(result[0].value).toBe(30);
|
||||
expect(result[1].value).toBe(30);
|
||||
});
|
||||
|
||||
it('returns items in series-index order', () => {
|
||||
// Series values in non-sorted order: 3, 1, 4, 2
|
||||
const data: AlignedData = [[0], [3], [1], [4], [2]];
|
||||
const series: Series[] = [
|
||||
{ label: 'x', show: true } as Series,
|
||||
{ label: 'C', show: true, stroke: '#aaaaaa' } as Series,
|
||||
{ label: 'A', show: true, stroke: '#bbbbbb' } as Series,
|
||||
{ label: 'D', show: true, stroke: '#cccccc' } as Series,
|
||||
{ label: 'B', show: true, stroke: '#dddddd' } as Series,
|
||||
];
|
||||
const dataIndexes = [null, 0, 0, 0, 0];
|
||||
const u = createUPlotInstance();
|
||||
|
||||
const result = buildTooltipContent({
|
||||
data,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: null,
|
||||
uPlotInstance: u,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toEqual([3, 1, 4, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.uplot-tooltip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
|
||||
.uplot-tooltip-item-marker {
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uplot-tooltip-item-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
||||
.uplot-tooltip-item-label {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&-separator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
|
||||
import Styles from './TooltipItem.module.scss';
|
||||
|
||||
interface TooltipItemProps {
|
||||
item: TooltipContentItem;
|
||||
isItemActive: boolean;
|
||||
containerTestId?: string;
|
||||
markerTestId?: string;
|
||||
contentTestId?: string;
|
||||
}
|
||||
|
||||
export default function TooltipItem({
|
||||
item,
|
||||
isItemActive,
|
||||
containerTestId = 'uplot-tooltip-item',
|
||||
markerTestId = 'uplot-tooltip-item-marker',
|
||||
contentTestId = 'uplot-tooltip-item-content',
|
||||
}: TooltipItemProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={Styles.uplotTooltipItem}
|
||||
style={{
|
||||
opacity: isItemActive ? 1 : 0.7,
|
||||
fontWeight: isItemActive ? 700 : 400,
|
||||
}}
|
||||
data-testid={containerTestId}
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemContent}
|
||||
style={{ color: item.color }}
|
||||
data-testid={contentTestId}
|
||||
>
|
||||
<span className={Styles.uplotTooltipItemLabel}>{item.label}</span>
|
||||
<span
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,16 +38,16 @@ export function getTooltipBaseValue({
|
||||
// When series are hidden, we must use the next *visible* series, not index+1,
|
||||
// since hidden series keep raw values and would produce negative/wrong results.
|
||||
if (isStackedBarChart && baseValue !== null && series) {
|
||||
let nextVisibleIdx = -1;
|
||||
for (let j = index + 1; j < series.length; j++) {
|
||||
if (series[j]?.show) {
|
||||
nextVisibleIdx = j;
|
||||
let nextVisibleSeriesIdx = -1;
|
||||
for (let seriesIdx = index + 1; seriesIdx < series.length; seriesIdx++) {
|
||||
if (series[seriesIdx]?.show) {
|
||||
nextVisibleSeriesIdx = seriesIdx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextVisibleIdx >= 1) {
|
||||
const nextValue = data[nextVisibleIdx][dataIndex] ?? 0;
|
||||
baseValue = baseValue - nextValue;
|
||||
if (nextVisibleSeriesIdx >= 1) {
|
||||
const nextStackedValue = data[nextVisibleSeriesIdx][dataIndex] ?? 0;
|
||||
baseValue = baseValue - nextStackedValue;
|
||||
}
|
||||
}
|
||||
return baseValue;
|
||||
@@ -72,16 +72,15 @@ export function buildTooltipContent({
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
}): TooltipContentItem[] {
|
||||
const active: TooltipContentItem[] = [];
|
||||
const rest: TooltipContentItem[] = [];
|
||||
const items: TooltipContentItem[] = [];
|
||||
|
||||
for (let index = 1; index < series.length; index += 1) {
|
||||
const s = series[index];
|
||||
if (!s?.show) {
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
if (!seriesItem?.show) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[index];
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
// Skip series with no data at the current cursor position
|
||||
if (dataIndex === null) {
|
||||
continue;
|
||||
@@ -89,30 +88,22 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
index,
|
||||
index: seriesIndex,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
series,
|
||||
});
|
||||
|
||||
const isActive = index === activeSeriesIndex;
|
||||
|
||||
if (Number.isFinite(baseValue) && baseValue !== null) {
|
||||
const item: TooltipContentItem = {
|
||||
label: String(s.label ?? ''),
|
||||
items.push({
|
||||
label: String(seriesItem.label ?? ''),
|
||||
value: baseValue,
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
|
||||
isActive,
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
active.push(item);
|
||||
} else {
|
||||
rest.push(item);
|
||||
}
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...active, ...rest];
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ const HOVER_DISMISS_DELAY_MS = 100;
|
||||
export default function TooltipPlugin({
|
||||
config,
|
||||
render,
|
||||
maxWidth = 300,
|
||||
maxHeight = 400,
|
||||
maxWidth = 450,
|
||||
maxHeight = 600,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
pinnedTooltipElement,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useQuery } from 'react-query';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useGetMyOrganization } from 'api/generated/services/orgs';
|
||||
import { useGetMyUser } from 'api/generated/services/users';
|
||||
import listOrgPreferences from 'api/v1/org/preferences/list';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
@@ -85,14 +84,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
query: { enabled: isLoggedIn },
|
||||
});
|
||||
|
||||
const {
|
||||
data: orgData,
|
||||
isFetching: isFetchingOrgData,
|
||||
error: orgFetchDataError,
|
||||
} = useGetMyOrganization({
|
||||
query: { enabled: isLoggedIn },
|
||||
});
|
||||
|
||||
const {
|
||||
permissions: permissionsResult,
|
||||
isFetching: isFetchingPermissions,
|
||||
@@ -102,10 +93,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
|
||||
const isFetchingUser =
|
||||
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
|
||||
const userFetchError =
|
||||
userFetchDataError || orgFetchDataError || errorOnPermissions;
|
||||
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
|
||||
const userFetchError = userFetchDataError || errorOnPermissions;
|
||||
|
||||
const userRole = useMemo(() => {
|
||||
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
|
||||
@@ -145,39 +134,40 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
|
||||
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
|
||||
}));
|
||||
}
|
||||
}, [userData, isFetchingUserData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingOrgData && orgData?.data) {
|
||||
const { id: orgId, displayName: orgDisplayName } = orgData.data;
|
||||
setOrg((prev) => {
|
||||
// todo: we need to update the org name as well, we should have the [admin only role restriction on the get org api call] - BE input needed
|
||||
setOrg((prev): any => {
|
||||
if (!prev) {
|
||||
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
|
||||
return [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
];
|
||||
}
|
||||
const orgIndex = prev.findIndex((e) => e.id === orgId);
|
||||
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
|
||||
|
||||
if (orgIndex === -1) {
|
||||
return [
|
||||
...prev,
|
||||
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const updatedOrg: Organization[] = [
|
||||
return [
|
||||
...prev.slice(0, orgIndex),
|
||||
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
...prev.slice(orgIndex + 1),
|
||||
];
|
||||
return updatedOrg;
|
||||
});
|
||||
|
||||
setDefaultUser((prev) => ({
|
||||
...prev,
|
||||
organization: orgDisplayName ?? prev.organization,
|
||||
}));
|
||||
}
|
||||
}, [orgData, isFetchingOrgData]);
|
||||
}, [userData, isFetchingUserData]);
|
||||
|
||||
// fetcher for licenses v3
|
||||
const {
|
||||
|
||||
@@ -281,48 +281,6 @@ describe('AppProvider user and org data from v2 APIs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('populates org state from GET /api/v2/orgs/me', async () => {
|
||||
server.use(
|
||||
rest.get(MY_ORG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
id: 'org-abc',
|
||||
displayName: 'My Org',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.get(MY_USER_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { id: 'u-default', email: 'default@signoz.io' } }),
|
||||
),
|
||||
),
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.org).not.toBeNull();
|
||||
const org = result.current.org?.[0];
|
||||
expect(org?.id).toBe('org-abc');
|
||||
expect(org?.displayName).toBe('My Org');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets isFetchingUser false once both user and org calls complete', async () => {
|
||||
server.use(
|
||||
rest.get(MY_USER_URL, (_, res, ctx) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import APIError from 'types/api/error';
|
||||
interface ErrorModalContextType {
|
||||
showErrorModal: (error: APIError) => void;
|
||||
hideErrorModal: () => void;
|
||||
isErrorModalVisible: boolean;
|
||||
}
|
||||
|
||||
const ErrorModalContext = createContext<ErrorModalContextType | undefined>(
|
||||
@@ -38,10 +39,10 @@ export function ErrorModalProvider({
|
||||
setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [
|
||||
showErrorModal,
|
||||
hideErrorModal,
|
||||
]);
|
||||
const value = useMemo(
|
||||
() => ({ showErrorModal, hideErrorModal, isErrorModalVisible: isVisible }),
|
||||
[showErrorModal, hideErrorModal, isVisible],
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorModalContext.Provider value={value}>
|
||||
|
||||
45
frontend/src/utils/errorUtils.test.ts
Normal file
45
frontend/src/utils/errorUtils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { retryOn429 } from './errorUtils';
|
||||
|
||||
describe('retryOn429', () => {
|
||||
const make429 = (): AxiosError =>
|
||||
Object.assign(new AxiosError('Too Many Requests'), {
|
||||
response: { status: 429 },
|
||||
}) as AxiosError;
|
||||
|
||||
it('returns true on first failure (failureCount=0) for 429', () => {
|
||||
expect(retryOn429(0, make429())).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true on second failure (failureCount=1) for 429', () => {
|
||||
expect(retryOn429(1, make429())).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on third failure (failureCount=2) for 429 — max retries reached', () => {
|
||||
expect(retryOn429(2, make429())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-429 axios errors', () => {
|
||||
const err = Object.assign(new AxiosError('Server Error'), {
|
||||
response: { status: 500 },
|
||||
}) as AxiosError;
|
||||
expect(retryOn429(0, err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 401 axios errors', () => {
|
||||
const err = Object.assign(new AxiosError('Unauthorized'), {
|
||||
response: { status: 401 },
|
||||
}) as AxiosError;
|
||||
expect(retryOn429(0, err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-axios errors', () => {
|
||||
expect(retryOn429(0, new Error('network error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined errors', () => {
|
||||
expect(retryOn429(0, null)).toBe(false);
|
||||
expect(retryOn429(0, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ErrorResponseHandlerForGeneratedAPIs } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
/**
|
||||
@@ -66,3 +67,10 @@ export function handleApiError(
|
||||
showErrorFunction(apiError as APIError);
|
||||
}
|
||||
}
|
||||
|
||||
export const retryOn429 = (failureCount: number, error: unknown): boolean => {
|
||||
if (error instanceof AxiosError && error.response?.status === 429) {
|
||||
return failureCount < 2;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -97,6 +97,9 @@ export default defineConfig(
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// TODO: Remove this in favor of import.meta.env
|
||||
|
||||
@@ -278,7 +278,8 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
|
||||
ruleId := getRuleIDFromAlert(alert)
|
||||
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
|
||||
if err != nil {
|
||||
d.logger.ErrorContext(d.ctx, "error getting alert notification config", slog.String("rule_id", ruleId), errors.Attr(err))
|
||||
//nolint:sloglint
|
||||
d.logger.ErrorContext(d.ctx, "error getting alert notification config", slog.String("rule.id", ruleId), errors.Attr(err))
|
||||
return
|
||||
}
|
||||
renotifyInterval := config.Renotify.RenotifyInterval
|
||||
@@ -328,7 +329,12 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
|
||||
go ag.run(func(ctx context.Context, alerts ...*types.Alert) bool {
|
||||
_, _, err := d.stage.Exec(ctx, d.logger, alerts...)
|
||||
if err != nil {
|
||||
logger := d.logger.With(slog.Int("num_alerts", len(alerts)), errors.Attr(err))
|
||||
receiverName, _ := notify.ReceiverName(ctx)
|
||||
logger := d.logger.With(
|
||||
slog.String("receiver", receiverName),
|
||||
slog.Int("num_alerts", len(alerts)),
|
||||
errors.Attr(err),
|
||||
)
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
// It is expected for the context to be canceled on
|
||||
// configuration reload or shutdown. In this case, the
|
||||
|
||||
@@ -240,6 +240,26 @@ func (m *MockNotificationManager) DeleteAllRoutePoliciesByName(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockNotificationManager) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
if orgID == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
|
||||
}
|
||||
|
||||
var matched []*alertmanagertypes.RoutePolicy
|
||||
for _, route := range m.routes {
|
||||
if route.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
for _, ch := range route.Channels {
|
||||
if ch == channelName {
|
||||
matched = append(matched, route)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (m *MockNotificationManager) Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error) {
|
||||
key := getKey(orgID, ruleID)
|
||||
if err := m.errors[key]; err != nil {
|
||||
|
||||
@@ -59,6 +59,10 @@ func (m *MockSQLRouteStore) DeleteRouteByName(ctx context.Context, orgID string,
|
||||
return m.routeStore.DeleteRouteByName(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (m *MockSQLRouteStore) GetAll(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
return m.routeStore.GetAll(ctx, orgID)
|
||||
}
|
||||
|
||||
func (m *MockSQLRouteStore) ExpectGetByID(orgID, id string, route *alertmanagertypes.RoutePolicy) {
|
||||
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})
|
||||
|
||||
|
||||
@@ -83,6 +83,18 @@ func (store *store) GetAllByName(ctx context.Context, orgID string, name string)
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAll(ctx context.Context, orgID string) ([]*routeTypes.RoutePolicy, error) {
|
||||
var routes []*routeTypes.RoutePolicy
|
||||
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policies for orgID: %s", orgID)
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteRouteByName(ctx context.Context, orgID string, name string) error {
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model((*routeTypes.RoutePolicy)(nil)).Where("org_id = ?", orgID).Where("name = ?", name).Exec(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,10 @@ type NotificationManager interface {
|
||||
DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error
|
||||
DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error
|
||||
|
||||
// GetRoutePoliciesByChannel returns all route policies (both rule-based and policy-based)
|
||||
// that reference the given channel name.
|
||||
GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error)
|
||||
|
||||
// Route matching
|
||||
Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error)
|
||||
}
|
||||
|
||||
@@ -155,6 +155,28 @@ func (r *provider) GetAllRoutePolicies(ctx context.Context, orgID string) ([]*al
|
||||
return r.routeStore.GetAllByKind(ctx, orgID, alertmanagertypes.PolicyBasedExpression)
|
||||
}
|
||||
|
||||
func (r *provider) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
if orgID == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
|
||||
}
|
||||
|
||||
allRoutes, err := r.routeStore.GetAll(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matched []*alertmanagertypes.RoutePolicy
|
||||
for _, route := range allRoutes {
|
||||
for _, ch := range route.Channels {
|
||||
if ch == channelName {
|
||||
matched = append(matched, route)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (r *provider) DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error {
|
||||
if routeID == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
|
||||
|
||||
@@ -169,6 +169,21 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if channel is referenced by any route policy (rule-based or policy-based)
|
||||
policies, err := provider.notificationManager.GetRoutePoliciesByChannel(ctx, orgID, channel.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(policies) > 0 {
|
||||
names := make([]string, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channel %q cannot be deleted because it is used by the following routing policies: %v",
|
||||
channel.Name, names)
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -150,7 +150,7 @@ func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []
|
||||
|
||||
err = provider.Write(ctx, tuples, nil)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to grant roles: %v to subject: %s", names, subject)
|
||||
return errors.WithAdditionalf(err, "failed to grant roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -188,7 +188,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
|
||||
|
||||
err = provider.Write(ctx, nil, tuples)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to revoke roles: %v to subject: %s", names, subject)
|
||||
return errors.WithAdditionalf(err, "failed to revoke roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -15,12 +15,14 @@ import (
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
|
||||
openfgapkgserver "github.com/openfga/openfga/pkg/server"
|
||||
openfgaerrors "github.com/openfga/openfga/pkg/server/errors"
|
||||
"github.com/openfga/openfga/pkg/storage"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
)
|
||||
|
||||
const (
|
||||
batchCheckItemErrorMessage = "::AUTHZ-CHECK-ERROR::"
|
||||
writeErrorMessage = "::AUTHZ-WRITE-ERROR::"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -248,7 +250,19 @@ func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey
|
||||
}(),
|
||||
})
|
||||
|
||||
return err
|
||||
if err != nil {
|
||||
openfgaError := new(openfgaerrors.InternalError)
|
||||
ok := errors.As(err, openfgaError)
|
||||
if ok {
|
||||
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(openfgaError.Unwrap()))
|
||||
return errors.New(errors.TypeTooManyRequests, errors.CodeTooManyRequests, openfgaError.Error())
|
||||
}
|
||||
|
||||
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
CodeUnknown = Code{"unknown"}
|
||||
CodeFatal = Code{"fatal"}
|
||||
CodeLicenseUnavailable = Code{"license_unavailable"}
|
||||
CodeTooManyRequests = Code{"too_many_requests"}
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -12,8 +12,9 @@ var (
|
||||
TypeCanceled = typ{"canceled"}
|
||||
TypeTimeout = typ{"timeout"}
|
||||
TypeUnexpected = typ{"unexpected"} // Generic mismatch of expectations
|
||||
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
|
||||
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
|
||||
TypeLicenseUnavailable = typ{"license-unavailable"}
|
||||
TypeTooManyRequests = typ{"too-many-requests"}
|
||||
)
|
||||
|
||||
// Defines custom error types.
|
||||
|
||||
@@ -77,6 +77,8 @@ func ErrorTypeFromStatusCode(statusCode int) string {
|
||||
return errors.TypeTimeout.String()
|
||||
case http.StatusUnavailableForLegalReasons:
|
||||
return errors.TypeLicenseUnavailable.String()
|
||||
case http.StatusTooManyRequests:
|
||||
return errors.TypeTooManyRequests.String()
|
||||
default:
|
||||
return errors.TypeInternal.String()
|
||||
}
|
||||
@@ -108,6 +110,8 @@ func Error(rw http.ResponseWriter, cause error) {
|
||||
httpCode = http.StatusInternalServerError
|
||||
case errors.TypeLicenseUnavailable:
|
||||
httpCode = http.StatusUnavailableForLegalReasons
|
||||
case errors.TypeTooManyRequests:
|
||||
httpCode = http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&ErrorResponse{Status: StatusError.s, Error: errors.AsJSON(cause)})
|
||||
|
||||
@@ -32,7 +32,7 @@ func newConfig() factory.Config {
|
||||
Domain: "signozserviceaccount.com",
|
||||
},
|
||||
Analytics: AnalyticsConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
|
||||
storableServiceAccount, err := module.store.GetActiveByID(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
}
|
||||
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -386,12 +386,24 @@ func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
err = module.authz.ModifyGrant(ctx, orgID, serviceAccount.RoleNames(), []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,6 +105,24 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetActiveByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("id = ?", id).
|
||||
Where("status = ?", serviceaccounttypes.ServiceAccountStatusActive).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with id: %s doesn't exist", id)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
|
||||
@@ -170,6 +188,21 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRoles(ctx context.Context, serviceAccountID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.ServiceAccountRole)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -66,6 +67,8 @@ type Module interface {
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
|
||||
Config() Config
|
||||
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@@ -383,6 +383,11 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot self delete")
|
||||
}
|
||||
|
||||
err = user.UpdateStatus(types.UserStatusDeleted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRoles, err := module.getter.GetRolesByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -406,6 +411,8 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return err
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Deleted", map[string]any{
|
||||
"deleted_by": deletedBy,
|
||||
})
|
||||
@@ -568,8 +575,13 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
|
||||
roleNames := roleNamesFromUserRoles(userRoles)
|
||||
|
||||
isPendingInviteUser := user.Status == types.UserStatusPendingInvite
|
||||
// since grant is idempotent, multiple calls won't cause issues in case of retries
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
if isPendingInviteUser {
|
||||
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = module.authz.Grant(
|
||||
ctx,
|
||||
user.OrgID,
|
||||
@@ -580,15 +592,14 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
|
||||
}
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
|
||||
if isPendingInviteUser {
|
||||
err := module.store.UpdateUser(ctx, user.OrgID, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -817,6 +828,7 @@ func (module *setter) activatePendingUser(ctx context.Context, user *types.User,
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
|
||||
|
||||
return nil
|
||||
@@ -866,16 +878,17 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userRole := range existingUserRoles {
|
||||
if userRole.Role != nil && userRole.Role.Name == roleName {
|
||||
return nil // role already assigned no-op
|
||||
}
|
||||
|
||||
existingRoles := make([]string, len(existingUserRoles))
|
||||
for idx, role := range existingUserRoles {
|
||||
existingRoles[idx] = role.Role.Name
|
||||
}
|
||||
|
||||
// grant via authz (idempotent)
|
||||
if err := module.authz.Grant(
|
||||
if err := module.authz.ModifyGrant(
|
||||
ctx,
|
||||
orgID,
|
||||
existingRoles,
|
||||
[]string{roleName},
|
||||
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), existingUser.OrgID, nil),
|
||||
); err != nil {
|
||||
@@ -884,7 +897,20 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
|
||||
// create user_role entry
|
||||
userRoles := authtypes.NewUserRoles(userID, foundRoles)
|
||||
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = module.userRoleStore.DeleteUserRoles(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := module.userRoleStore.CreateUserRoles(ctx, userRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -383,15 +383,15 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
spec.Aggregations[i].Temporality = temp
|
||||
}
|
||||
}
|
||||
if spec.Aggregations[i].Temporality == metrictypes.Unknown {
|
||||
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
|
||||
continue
|
||||
}
|
||||
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
|
||||
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
|
||||
spec.Aggregations[i].Type = foundMetricType
|
||||
}
|
||||
}
|
||||
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
|
||||
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
|
||||
continue
|
||||
}
|
||||
presentAggregations = append(presentAggregations, spec.Aggregations[i])
|
||||
}
|
||||
if len(presentAggregations) == 0 {
|
||||
|
||||
145
pkg/querier/querier_test.go
Normal file
145
pkg/querier/querier_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/srikanthccv/ClickHouse-go-mock"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type queryMatcherAny struct{}
|
||||
|
||||
func (m *queryMatcherAny) Match(string, string) error { return nil }
|
||||
|
||||
// mockMetricStmtBuilder implements qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
// and returns a fixed query string so the mock ClickHouse can match it.
|
||||
type mockMetricStmtBuilder struct{}
|
||||
|
||||
func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.RequestType, _ qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
|
||||
return &qbtypes.Statement{
|
||||
Query: "SELECT ts, value FROM signoz_metrics",
|
||||
Args: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
// When a metric has UnspecifiedType and is not found in the metadata store,
|
||||
// the querier should return a not-found error, even if the request provides a temporality
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
q := New(
|
||||
providerSettings,
|
||||
nil, // telemetryStore
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(time.Now().Add(-5 * time.Minute).UnixMilli()),
|
||||
End: uint64(time.Now().UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeTimeSeries,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
StepInterval: qbtypes.Step{Duration: time.Minute},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "unknown_metric",
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
|
||||
}
|
||||
|
||||
func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
// When a metric has UnspecifiedType but the metadata store returns a valid type,
|
||||
// the metric should not be treated as missing.
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
metadataStore.TypeMap["my_metric"] = metrictypes.SumType
|
||||
metadataStore.TemporalityMap["my_metric"] = metrictypes.Cumulative
|
||||
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
||||
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "value", Type: "Float64"},
|
||||
}
|
||||
rows := cmock.NewRows(cols, [][]any{
|
||||
{time.Now(), float64(42)},
|
||||
})
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT any").
|
||||
WillReturnRows(rows)
|
||||
|
||||
q := New(
|
||||
providerSettings,
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
&mockMetricStmtBuilder{}, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(time.Now().Add(-5 * time.Minute).UnixMilli()),
|
||||
End: uint64(time.Now().UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeTimeSeries,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
StepInterval: qbtypes.Step{Duration: time.Minute},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "my_metric",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func WithEvalDelay(dur valuer.TextDuration) RuleOption {
|
||||
|
||||
func WithLogger(logger *slog.Logger) RuleOption {
|
||||
return func(r *BaseRule) {
|
||||
r.logger = logger
|
||||
r.logger = logger.With(slog.String("rule.id", r.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,6 @@ func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
|
||||
}
|
||||
|
||||
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts ...RuleOption) (*BaseRule, error) {
|
||||
if p.RuleCondition == nil || !p.RuleCondition.IsValid() {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule condition")
|
||||
}
|
||||
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -251,7 +248,7 @@ func (r *BaseRule) SelectedQuery(ctx context.Context) string {
|
||||
if r.ruleCondition.SelectedQuery != "" {
|
||||
return r.ruleCondition.SelectedQuery
|
||||
}
|
||||
r.logger.WarnContext(ctx, "missing selected query", slog.String("rule.id", r.ID()))
|
||||
r.logger.WarnContext(ctx, "missing selected query")
|
||||
return r.ruleCondition.SelectedQueryName()
|
||||
}
|
||||
|
||||
@@ -371,7 +368,7 @@ func (r *BaseRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay tim
|
||||
alerts = append(alerts, &anew)
|
||||
}
|
||||
})
|
||||
notifyFunc(ctx, orgID, "", alerts...)
|
||||
notifyFunc(ctx, orgID, alerts...)
|
||||
}
|
||||
|
||||
func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
|
||||
@@ -383,13 +380,13 @@ func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState ruletypes.AlertState, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
|
||||
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
|
||||
if r.ruleStateHistoryModule == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.ruleStateHistoryModule.RecordRuleStateHistory(ctx, r.ID(), r.handledRestart, itemsToAdd); err != nil {
|
||||
r.logger.ErrorContext(ctx, "error while recording rule state history", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
|
||||
r.logger.ErrorContext(ctx, "error while recording rule state history", errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
|
||||
return err
|
||||
}
|
||||
r.handledRestart = true
|
||||
@@ -583,7 +580,12 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
|
||||
// Check if first_seen + delay has passed
|
||||
if maxFirstSeen+newGroupEvalDelayMs > evalTimeMs {
|
||||
// Still within grace period, skip this series
|
||||
r.logger.InfoContext(ctx, "skipping new series", slog.String("rule.id", r.ID()), slog.Int("series.index", i), slog.Int64("series.max_first_seen", maxFirstSeen), slog.Int64("eval.time_ms", evalTimeMs), slog.Int64("eval.delay_ms", newGroupEvalDelayMs), slog.Any("series.labels", series[i].Labels))
|
||||
r.logger.InfoContext(
|
||||
ctx, "skipping new series",
|
||||
slog.Int("series.index", i), slog.Int64("series.max_first_seen", maxFirstSeen),
|
||||
slog.Int64("eval.time_ms", evalTimeMs), slog.Int64("eval.delay_ms", newGroupEvalDelayMs),
|
||||
slog.Any("series.labels", series[i].Labels),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -593,7 +595,11 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
|
||||
|
||||
skippedCount := len(series) - len(filteredSeries)
|
||||
if skippedCount > 0 {
|
||||
r.logger.InfoContext(ctx, "filtered new series", slog.String("rule.id", r.ID()), slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)), slog.Int64("eval.delay_ms", newGroupEvalDelayMs))
|
||||
r.logger.InfoContext(
|
||||
ctx, "filtered new series",
|
||||
slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)),
|
||||
slog.Int64("eval.delay_ms", newGroupEvalDelayMs),
|
||||
)
|
||||
}
|
||||
|
||||
return filteredSeries, nil
|
||||
@@ -614,7 +620,7 @@ func (r *BaseRule) HandleMissingDataAlert(ctx context.Context, ts time.Time, has
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logger.InfoContext(ctx, "no data found for rule condition", slog.String("rule.id", r.ID()))
|
||||
r.logger.InfoContext(ctx, "no data found for rule condition")
|
||||
lbls := ruletypes.NewBuilder()
|
||||
if !r.lastTimestampWithDatapoints.IsZero() {
|
||||
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(ruletypes.AlertTimeFormat))
|
||||
|
||||
@@ -320,6 +320,38 @@ func (m *Manager) Stop(_ context.Context) {
|
||||
m.logger.Info("rule manager stopped")
|
||||
}
|
||||
|
||||
// validateChannels checks that every channel referenced by the rule
|
||||
// exists as a notification channel for the given org.
|
||||
func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *ruletypes.PostableRule) error {
|
||||
channels := rule.Channels()
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
orgChannels, err := m.alertmanager.ListChannels(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(orgChannels))
|
||||
for _, ch := range orgChannels {
|
||||
known[ch.Name] = struct{}{}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for _, name := range channels {
|
||||
if _, ok := known[name]; !ok {
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unknown) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channels: the following channels do not exist: %v", unknown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditRule writes the rule definition to the
|
||||
// datastore and also updates the rule executor
|
||||
func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
@@ -336,7 +368,12 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
|
||||
return err
|
||||
}
|
||||
existingRule, err := m.ruleStore.GetStoredRule(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -401,7 +438,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.prepareNotifyFunc(),
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
@@ -533,7 +570,12 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
storedRule := &ruletypes.Rule{
|
||||
Identifiable: types.Identifiable{
|
||||
@@ -609,7 +651,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.prepareNotifyFunc(),
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
@@ -710,70 +752,65 @@ func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
|
||||
}
|
||||
|
||||
// NotifyFunc sends notifications about a set of alerts generated by the given expression.
|
||||
type NotifyFunc func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert)
|
||||
type NotifyFunc func(ctx context.Context, orgID string, alerts ...*ruletypes.Alert)
|
||||
|
||||
// prepareNotifyFunc implements the NotifyFunc for a Notifier.
|
||||
func (m *Manager) prepareNotifyFunc() NotifyFunc {
|
||||
return func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert) {
|
||||
var res []*alertmanagertypes.PostableAlert
|
||||
// notifyFunc implements the NotifyFunc for a Notifier.
|
||||
func (m *Manager) notifyFunc(ctx context.Context, orgID string, alerts ...*ruletypes.Alert) {
|
||||
var res []*alertmanagertypes.PostableAlert
|
||||
|
||||
for _, alert := range alerts {
|
||||
generatorURL := alert.GeneratorURL
|
||||
for _, alert := range alerts {
|
||||
generatorURL := alert.GeneratorURL
|
||||
|
||||
a := &alertmanagertypes.PostableAlert{
|
||||
Annotations: alert.Annotations.Map(),
|
||||
StartsAt: strfmt.DateTime(alert.FiredAt),
|
||||
Alert: alertmanagertypes.AlertModel{
|
||||
Labels: alert.Labels.Map(),
|
||||
GeneratorURL: strfmt.URI(generatorURL),
|
||||
},
|
||||
}
|
||||
if !alert.ResolvedAt.IsZero() {
|
||||
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
||||
} else {
|
||||
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
||||
}
|
||||
|
||||
res = append(res, a)
|
||||
a := &alertmanagertypes.PostableAlert{
|
||||
Annotations: alert.Annotations.Map(),
|
||||
StartsAt: strfmt.DateTime(alert.FiredAt),
|
||||
Alert: alertmanagertypes.AlertModel{
|
||||
Labels: alert.Labels.Map(),
|
||||
GeneratorURL: strfmt.URI(generatorURL),
|
||||
},
|
||||
}
|
||||
if !alert.ResolvedAt.IsZero() {
|
||||
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
||||
} else {
|
||||
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
||||
}
|
||||
|
||||
if len(alerts) > 0 {
|
||||
m.alertmanager.PutAlerts(ctx, orgID, res)
|
||||
}
|
||||
res = append(res, a)
|
||||
}
|
||||
|
||||
if len(alerts) > 0 {
|
||||
m.alertmanager.PutAlerts(ctx, orgID, res)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
|
||||
return func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert) {
|
||||
if len(alerts) == 0 {
|
||||
return
|
||||
}
|
||||
ruleID := alerts[0].Labels.Map()[ruletypes.AlertRuleIDLabel]
|
||||
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
|
||||
for _, alert := range alerts {
|
||||
generatorURL := alert.GeneratorURL
|
||||
func (m *Manager) testNotifyFunc(ctx context.Context, orgID string, alerts ...*ruletypes.Alert) {
|
||||
if len(alerts) == 0 {
|
||||
return
|
||||
}
|
||||
ruleID := alerts[0].Labels.Map()[ruletypes.AlertRuleIDLabel]
|
||||
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
|
||||
for _, alert := range alerts {
|
||||
generatorURL := alert.GeneratorURL
|
||||
|
||||
a := &alertmanagertypes.PostableAlert{}
|
||||
a.Annotations = alert.Annotations.Map()
|
||||
a.StartsAt = strfmt.DateTime(alert.FiredAt)
|
||||
labelsMap := alert.Labels.Map()
|
||||
labelsMap[ruletypes.TestAlertLabel] = "true"
|
||||
a.Alert = alertmanagertypes.AlertModel{
|
||||
Labels: labelsMap,
|
||||
GeneratorURL: strfmt.URI(generatorURL),
|
||||
}
|
||||
if !alert.ResolvedAt.IsZero() {
|
||||
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
||||
} else {
|
||||
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
||||
}
|
||||
receiverMap[a] = alert.Receivers
|
||||
a := &alertmanagertypes.PostableAlert{}
|
||||
a.Annotations = alert.Annotations.Map()
|
||||
a.StartsAt = strfmt.DateTime(alert.FiredAt)
|
||||
labelsMap := alert.Labels.Map()
|
||||
labelsMap[ruletypes.TestAlertLabel] = "true"
|
||||
a.Alert = alertmanagertypes.AlertModel{
|
||||
Labels: labelsMap,
|
||||
GeneratorURL: strfmt.URI(generatorURL),
|
||||
}
|
||||
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to send test notification", errors.Attr(err))
|
||||
return
|
||||
if !alert.ResolvedAt.IsZero() {
|
||||
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
||||
} else {
|
||||
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
||||
}
|
||||
receiverMap[a] = alert.Receivers
|
||||
}
|
||||
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to send test notification", errors.Attr(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,7 +957,12 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := storedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// deploy or un-deploy task according to patched (new) rule state
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
|
||||
@@ -971,6 +1013,12 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
if err != nil {
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, orgID.StringValue(), &parsedRule); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !parsedRule.NotificationSettings.UsePolicy {
|
||||
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
|
||||
}
|
||||
@@ -988,7 +1036,7 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.prepareTestNotifyFunc(),
|
||||
NotifyFunc: m.testNotifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
|
||||
@@ -48,14 +48,13 @@ func NewPromRule(
|
||||
version: postableRule.Version,
|
||||
prometheus: prometheus,
|
||||
}
|
||||
p.logger = logger
|
||||
|
||||
query, err := p.getPqlQuery(context.Background())
|
||||
if err != nil {
|
||||
// can not generate a valid prom QL query
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("creating new prom rule", slog.String("rule.id", id), slog.String("rule.query", query))
|
||||
p.logger.Info("creating new prom rule", slog.String("rule.query", query))
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -97,7 +96,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.logger.InfoContext(ctx, "evaluating promql query", slog.String("rule.id", r.ID()), slog.String("rule.query", q))
|
||||
r.logger.InfoContext(ctx, "evaluating promql query", slog.String("rule.query", q))
|
||||
res, err := r.RunAlertQuery(ctx, q, start, end, interval)
|
||||
if err != nil {
|
||||
r.SetHealth(ruletypes.HealthBad)
|
||||
@@ -117,7 +116,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
|
||||
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, matrixToProcess)
|
||||
// In case of error we log the error and continue with the original series
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
|
||||
} else {
|
||||
matrixToProcess = filteredSeries
|
||||
}
|
||||
@@ -129,7 +128,8 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
|
||||
if !r.Condition().ShouldEval(series) {
|
||||
r.logger.InfoContext(
|
||||
ctx, "not enough data points to evaluate series, skipping",
|
||||
"rule.id", r.ID(), "num_points", len(series.Values), "required_points", r.Condition().RequiredNumPoints,
|
||||
slog.Int("series.num_points", len(series.Values)),
|
||||
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
for _, lbl := range result.Metric {
|
||||
l[lbl.Name] = lbl.Value
|
||||
}
|
||||
r.logger.DebugContext(ctx, "alerting for series", slog.String("rule.id", r.ID()), slog.Any("series", result))
|
||||
r.logger.DebugContext(ctx, "alerting for series", slog.Any("series", result))
|
||||
|
||||
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
|
||||
|
||||
@@ -193,7 +193,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
result, err := tmpl.Expand()
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("<error expanding template: %s>", err)
|
||||
r.logger.WarnContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
r.logger.WarnContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
|
||||
// alerts[h] is ready, add or update active list now
|
||||
for h, a := range alerts {
|
||||
// Check whether we already have alerting state for the identifying label set.
|
||||
@@ -271,7 +271,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
for fp, a := range r.Active {
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLabels)
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err))
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err))
|
||||
}
|
||||
if _, ok := resultFPs[fp]; !ok {
|
||||
// If the alert was previously firing, keep it around for a given
|
||||
@@ -325,7 +325,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
state = ruletypes.StateFiring
|
||||
}
|
||||
a.State = state
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
@@ -350,7 +350,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
itemsToAdd[idx] = item
|
||||
}
|
||||
|
||||
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
|
||||
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
|
||||
|
||||
return len(r.Active), nil
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@ type Rule interface {
|
||||
SetEvaluationTimestamp(time.Time)
|
||||
GetEvaluationTimestamp() time.Time
|
||||
|
||||
RecordRuleStateHistory(
|
||||
ctx context.Context,
|
||||
prevState, currentState ruletypes.AlertState,
|
||||
itemsToAdd []rulestatehistorytypes.RuleStateHistory,
|
||||
) error
|
||||
RecordRuleStateHistory(ctx context.Context, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error
|
||||
|
||||
SendAlerts(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -2,6 +2,7 @@ package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -308,7 +309,10 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.logger.ErrorContext(ctx, "panic during threshold rule evaluation", "panic", r)
|
||||
g.logger.ErrorContext(
|
||||
ctx, "panic during rule evaluation", slog.Any("panic", r),
|
||||
slog.String("stack", string(debug.Stack())),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.TelemetryStore) querier.Querier {
|
||||
func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.TelemetryStore) (querier.Querier, *telemetrytypestest.MockMetadataStore) {
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
@@ -50,7 +50,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
)
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
func prepareQuerierForLogs(telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewThresholdRule(
|
||||
logger *slog.Logger,
|
||||
opts ...RuleOption,
|
||||
) (*ThresholdRule, error) {
|
||||
logger.Info("creating new ThresholdRule", "id", id)
|
||||
logger.Info("creating new ThresholdRule", slog.String("rule.id", id))
|
||||
|
||||
opts = append(opts, WithLogger(logger))
|
||||
|
||||
@@ -76,7 +76,6 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
|
||||
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
|
||||
slog.String("rule.id", r.ID()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
@@ -199,7 +198,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
|
||||
results = append(results, tsData)
|
||||
} else {
|
||||
// NOTE: should not happen but just to ensure we don't miss it if it happens for some reason
|
||||
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got unexpected type", slog.String("rule.id", r.ID()), slog.String("item.type", reflect.TypeOf(item).String()))
|
||||
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got unexpected type", slog.String("item.type", reflect.TypeOf(item).String()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +224,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
|
||||
var resultVector ruletypes.Vector
|
||||
|
||||
if queryResult == nil || len(queryResult.Aggregations) == 0 || queryResult.Aggregations[0] == nil {
|
||||
r.logger.WarnContext(ctx, "query result is nil", slog.String("rule.id", r.ID()), slog.String("query.name", selectedQuery))
|
||||
r.logger.WarnContext(ctx, "query result is nil", slog.String("query.name", selectedQuery))
|
||||
return resultVector, nil
|
||||
}
|
||||
|
||||
@@ -235,7 +234,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
|
||||
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
|
||||
// In case of error we log the error and continue with the original series
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
|
||||
} else {
|
||||
seriesToProcess = filteredSeries
|
||||
}
|
||||
@@ -243,7 +242,11 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
|
||||
|
||||
for _, series := range seriesToProcess {
|
||||
if !r.Condition().ShouldEval(series) {
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
|
||||
r.logger.InfoContext(
|
||||
ctx, "not enough data points to evaluate series, skipping",
|
||||
slog.Int("series.num_points", len(series.Values)),
|
||||
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
|
||||
)
|
||||
continue
|
||||
}
|
||||
resultSeries, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
|
||||
@@ -294,7 +297,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
value := valueFormatter.Format(smpl.V, r.Unit())
|
||||
// todo(aniket): handle different threshold
|
||||
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
|
||||
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
|
||||
r.logger.DebugContext(
|
||||
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
|
||||
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
|
||||
)
|
||||
|
||||
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
|
||||
// Inject some convenience variables that are easier to remember for users
|
||||
@@ -313,7 +319,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
result, err := tmpl.Expand()
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("<error expanding template: %s>", err)
|
||||
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -345,13 +351,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
case ruletypes.AlertTypeTraces:
|
||||
link := r.prepareLinksToTraces(ctx, ts, smpl.Metric)
|
||||
if link != "" && r.hostFromSource() != "" {
|
||||
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
|
||||
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
|
||||
annotations = append(annotations, ruletypes.Label{Name: "related_traces", Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
|
||||
}
|
||||
case ruletypes.AlertTypeLogs:
|
||||
link := r.prepareLinksToLogs(ctx, ts, smpl.Metric)
|
||||
if link != "" && r.hostFromSource() != "" {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
|
||||
annotations = append(annotations, ruletypes.Label{Name: "related_logs", Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
|
||||
}
|
||||
}
|
||||
@@ -378,7 +384,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
|
||||
|
||||
// alerts[h] is ready, add or update active list now
|
||||
for h, a := range alerts {
|
||||
@@ -406,7 +412,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
for fp, a := range r.Active {
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLabels)
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
|
||||
}
|
||||
if _, ok := resultFPs[fp]; !ok {
|
||||
// If the alert was previously firing, keep it around for a given
|
||||
@@ -415,7 +421,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
delete(r.Active, fp)
|
||||
}
|
||||
if a.State != ruletypes.StateInactive {
|
||||
r.logger.DebugContext(ctx, "converting firing alert to inactive", slog.String("rule.id", r.ID()))
|
||||
r.logger.DebugContext(ctx, "converting firing alert to inactive")
|
||||
a.State = ruletypes.StateInactive
|
||||
a.ResolvedAt = ts
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
@@ -433,7 +439,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
|
||||
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
|
||||
r.logger.DebugContext(ctx, "converting pending alert to firing", slog.String("rule.id", r.ID()))
|
||||
r.logger.DebugContext(ctx, "converting pending alert to firing")
|
||||
a.State = ruletypes.StateFiring
|
||||
a.FiredAt = ts
|
||||
state := ruletypes.StateFiring
|
||||
@@ -463,7 +469,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
state = ruletypes.StateFiring
|
||||
}
|
||||
a.State = state
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
@@ -486,7 +492,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
itemsToAdd[idx] = item
|
||||
}
|
||||
|
||||
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
|
||||
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
|
||||
|
||||
r.health = ruletypes.HealthGood
|
||||
r.lastError = err
|
||||
|
||||
@@ -511,7 +511,8 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
}
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
||||
|
||||
querier := prepareQuerierForMetrics(t, telemetryStore)
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
@@ -727,7 +728,8 @@ func TestThresholdRuleNoData(t *testing.T) {
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
|
||||
querier := prepareQuerierForMetrics(t, telemetryStore)
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
var target float64 = 0
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
@@ -1115,7 +1117,8 @@ func TestMultipleThresholdRule(t *testing.T) {
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
|
||||
querier := prepareQuerierForMetrics(t, telemetryStore)
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
@@ -1903,7 +1906,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
|
||||
querier := prepareQuerierForMetrics(t, telemetryStore)
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
rc := postableRule.RuleCondition
|
||||
rc.Target = &c.target
|
||||
|
||||
@@ -211,6 +211,8 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
case float64:
|
||||
// try to convert the string value to to number
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
case []any:
|
||||
if allFloats(v) {
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
@@ -277,6 +279,18 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
case telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
tblFieldName = castString(tblFieldName)
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
tblFieldName = accurateCastFloat(tblFieldName)
|
||||
case bool:
|
||||
tblFieldName = castBool(tblFieldName)
|
||||
case []any:
|
||||
// dynamic array elements will be default casted to string
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
return tblFieldName, value
|
||||
}
|
||||
@@ -284,6 +298,10 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func accurateCastFloat(col string) string {
|
||||
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
|
||||
}
|
||||
|
||||
func allFloats(in []any) bool {
|
||||
for _, x := range in {
|
||||
|
||||
@@ -133,7 +133,8 @@ func (r *rule) GetStoredRulesByMetricName(ctx context.Context, orgID string, met
|
||||
for _, storedRule := range storedRules {
|
||||
var ruleData ruletypes.PostableRule
|
||||
if err := json.Unmarshal([]byte(storedRule.Data), &ruleData); err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to unmarshal rule data", slog.String("rule_id", storedRule.ID.StringValue()), errors.Attr(err))
|
||||
//nolint:sloglint
|
||||
r.logger.WarnContext(ctx, "failed to unmarshal rule data", slog.String("rule.id", storedRule.ID.StringValue()), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -437,6 +437,7 @@ func New(
|
||||
tokenizer,
|
||||
config,
|
||||
modules.AuthDomain,
|
||||
modules.ServiceAccount,
|
||||
}
|
||||
|
||||
// Initialize stats reporter from the available stats reporter provider factories
|
||||
|
||||
@@ -3,6 +3,7 @@ package telemetrylogs
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -33,182 +34,34 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
|
||||
|
||||
// BuildCondition builds the full WHERE condition for body_v2 JSON paths.
|
||||
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conditions := []string{}
|
||||
for _, node := range c.key.JSONPlan {
|
||||
condition, err := c.emitPlannedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, condition)
|
||||
baseCond, err := c.emitPlannedCondition(operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
baseCond := sb.Or(conditions...)
|
||||
|
||||
// path index
|
||||
if operator.AddDefaultExistsFilter() {
|
||||
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
|
||||
return sb.And(baseCond, pathIndex), nil
|
||||
}
|
||||
|
||||
return baseCond, nil
|
||||
}
|
||||
|
||||
// emitPlannedCondition handles paths with array traversal.
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
// Build traversal + terminal recursively per-hop
|
||||
compiled, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition.
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
conditions := []string{}
|
||||
// if the value type is not an array
|
||||
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
|
||||
if !c.valueType.IsArray {
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
}
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
// or the conditions together
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available.
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
var formattedValue = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == elemType && index.ColumnExpression == fieldPath
|
||||
})
|
||||
if elemType.IndexSupported && indexed {
|
||||
indexedExpr := assumeNotNull(fieldPath, elemType)
|
||||
emptyValue := func() any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = emptyValue
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = emptyValue
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, indexedComparisonValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, indexedComparisonValue)
|
||||
for _, node := range c.key.JSONPlan {
|
||||
condition, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != emptyValue {
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, cond)
|
||||
// Switch operator to EXISTS since indexed paths on assumedNotNull, indexes will always have a default value
|
||||
// So we flip the operator to Exists and filter the rows that actually have the value
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition handles array membership checks.
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
localKeyCopy := *node.TerminalConfig.Key
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
// Change the field data type from []dynamic to the value type
|
||||
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
|
||||
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
|
||||
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
|
||||
c.valueType.StringValue(),
|
||||
c.valueType.StringValue(),
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
// recurseArrayHops recursively builds array traversal conditions.
|
||||
func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
@@ -222,6 +75,33 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
|
||||
return terminalCond, nil
|
||||
}
|
||||
|
||||
// apply NOT at top level arrayExists so that any subsequent arrayExists fails we count it as true (matching log)
|
||||
yes, operator := applyNotCondition(operator)
|
||||
condition, err := c.buildAccessNodeBranches(current, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(condition), nil
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOperator) {
|
||||
if operator.IsNegativeOperator() {
|
||||
return true, operator.Inverse()
|
||||
}
|
||||
return false, operator
|
||||
}
|
||||
|
||||
// buildAccessNodeBranches builds conditions for each branch of the access node.
|
||||
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
}
|
||||
|
||||
currAlias := current.Alias()
|
||||
fieldPath := current.FieldPath()
|
||||
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
|
||||
@@ -256,6 +136,200 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
|
||||
return sb.Or(branches...), nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition.
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
// Note: here applyNotCondition will return true only if; top level path is an array; and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
yes, operator := applyNotCondition(operator)
|
||||
cond, err := c.buildTerminalArrayCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(cond), nil
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
func getEmptyValue(elemType telemetrytypes.JSONDataType) any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
indexedExpr := assumeNotNull(dynamicExpr)
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = getEmptyValue(elemType)
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = getEmptyValue(elemType)
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available.
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
//
|
||||
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == node.TerminalConfig.ElemType
|
||||
})
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed {
|
||||
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
|
||||
return indexCond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, indexCond)
|
||||
|
||||
// Switch operator to EXISTS except when operator is NOT EXISTS since
|
||||
// indexed paths on assumedNotNull, indexes will always have a default
|
||||
// value so we flip the operator to Exists and filter the rows that
|
||||
// actually have the value
|
||||
if operator != qbtypes.FilterOperatorNotExists {
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
}
|
||||
|
||||
var formattedValue = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
|
||||
|
||||
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
|
||||
//
|
||||
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
if node.IsNonNestedPath() {
|
||||
yes, _ := applyNotCondition(operator)
|
||||
if yes {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
// skip
|
||||
default:
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) buildTerminalArrayCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conditions := []string{}
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition builds condition of the part where Arrays becomes primitive typed Arrays
|
||||
// e.g. [300, 404, 500], and value operations will work on the array elements.
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), %s)",
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
@@ -317,6 +391,6 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
|
||||
}
|
||||
}
|
||||
|
||||
func assumeNotNull(column string, elemType telemetrytypes.JSONDataType) string {
|
||||
return fmt.Sprintf("assumeNotNull(dynamicElement(%s, '%s'))", column, elemType.StringValue())
|
||||
func assumeNotNull(fieldExpr string) string {
|
||||
return fmt.Sprintf("assumeNotNull(%s)", fieldExpr)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -136,4 +136,5 @@ type RouteStore interface {
|
||||
GetAllByKind(ctx context.Context, orgID string, kind ExpressionKind) ([]*RoutePolicy, error)
|
||||
GetAllByName(ctx context.Context, orgID string, name string) ([]*RoutePolicy, error)
|
||||
DeleteRouteByName(ctx context.Context, orgID string, name string) error
|
||||
GetAll(ctx context.Context, orgID string) ([]*RoutePolicy, error)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,29 @@ const (
|
||||
FilterOperatorNotContains
|
||||
)
|
||||
|
||||
var operatorInverseMapping = map[FilterOperator]FilterOperator{
|
||||
FilterOperatorEqual: FilterOperatorNotEqual,
|
||||
FilterOperatorNotEqual: FilterOperatorEqual,
|
||||
FilterOperatorGreaterThan: FilterOperatorLessThanOrEq,
|
||||
FilterOperatorGreaterThanOrEq: FilterOperatorLessThan,
|
||||
FilterOperatorLessThan: FilterOperatorGreaterThanOrEq,
|
||||
FilterOperatorLessThanOrEq: FilterOperatorGreaterThan,
|
||||
FilterOperatorLike: FilterOperatorNotLike,
|
||||
FilterOperatorNotLike: FilterOperatorLike,
|
||||
FilterOperatorILike: FilterOperatorNotILike,
|
||||
FilterOperatorNotILike: FilterOperatorILike,
|
||||
FilterOperatorBetween: FilterOperatorNotBetween,
|
||||
FilterOperatorNotBetween: FilterOperatorBetween,
|
||||
FilterOperatorIn: FilterOperatorNotIn,
|
||||
FilterOperatorNotIn: FilterOperatorIn,
|
||||
FilterOperatorExists: FilterOperatorNotExists,
|
||||
FilterOperatorNotExists: FilterOperatorExists,
|
||||
FilterOperatorRegexp: FilterOperatorNotRegexp,
|
||||
FilterOperatorNotRegexp: FilterOperatorRegexp,
|
||||
FilterOperatorContains: FilterOperatorNotContains,
|
||||
FilterOperatorNotContains: FilterOperatorContains,
|
||||
}
|
||||
|
||||
// AddDefaultExistsFilter returns true if addl exists filter should be added to the query
|
||||
// For the negative predicates, we don't want to add the exists filter. Why?
|
||||
// Say for example, user adds a filter `service.name != "redis"`, we can't interpret it
|
||||
@@ -162,6 +185,10 @@ func (f FilterOperator) IsNegativeOperator() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (f FilterOperator) Inverse() FilterOperator {
|
||||
return operatorInverseMapping[f]
|
||||
}
|
||||
|
||||
func (f FilterOperator) IsComparisonOperator() bool {
|
||||
switch f {
|
||||
case FilterOperatorGreaterThan, FilterOperatorGreaterThanOrEq, FilterOperatorLessThan, FilterOperatorLessThanOrEq:
|
||||
|
||||
@@ -103,7 +103,7 @@ type RuleCondition struct {
|
||||
MatchType MatchType `json:"matchType"`
|
||||
TargetUnit string `json:"targetUnit,omitempty"`
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
Seasonality string `json:"seasonality,omitempty"`
|
||||
Seasonality Seasonality `json:"seasonality,omitzero"`
|
||||
SelectedQuery string `json:"selectedQueryName,omitempty"`
|
||||
RequireMinPoints bool `json:"requireMinPoints,omitempty"`
|
||||
RequiredNumPoints int `json:"requiredNumPoints,omitempty"`
|
||||
@@ -158,10 +158,6 @@ func (rc *RuleCondition) SelectedQueryName() string {
|
||||
return keys[len(keys)-1]
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) IsValid() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ShouldEval checks if the further series should be evaluated at all for alerts.
|
||||
func (rc *RuleCondition) ShouldEval(series *qbtypes.TimeSeries) bool {
|
||||
return !rc.RequireMinPoints || len(series.Values) >= rc.RequiredNumPoints
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -25,7 +26,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSchemaVersion = "v1"
|
||||
DefaultSchemaVersion = "v1"
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
type RuleDataKind string
|
||||
@@ -39,9 +41,9 @@ type PostableRule struct {
|
||||
AlertName string `json:"alert"`
|
||||
AlertType AlertType `json:"alertType,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RuleType RuleType `json:"ruleType,omitempty"`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitempty"`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitempty"`
|
||||
RuleType RuleType `json:"ruleType,omitzero"`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitzero"`
|
||||
|
||||
RuleCondition *RuleCondition `json:"condition,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
@@ -64,7 +66,7 @@ type PostableRule struct {
|
||||
|
||||
type NotificationSettings struct {
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
Renotify Renotify `json:"renotify,omitempty"`
|
||||
Renotify Renotify `json:"renotify,omitzero"`
|
||||
UsePolicy bool `json:"usePolicy,omitempty"`
|
||||
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
|
||||
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
|
||||
@@ -93,6 +95,28 @@ func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanager
|
||||
return alertmanagertypes.NewNotificationConfig(ns.GroupBy, renotifyInterval, noDataRenotifyInterval, ns.UsePolicy)
|
||||
}
|
||||
|
||||
// Channels returns all unique channel names referenced by the rule's thresholds.
|
||||
func (r *PostableRule) Channels() []string {
|
||||
if r.RuleCondition == nil || r.RuleCondition.Thresholds == nil {
|
||||
return nil
|
||||
}
|
||||
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var channels []string
|
||||
for _, receiver := range threshold.GetRuleReceivers() {
|
||||
for _, ch := range receiver.Channels {
|
||||
if _, ok := seen[ch]; !ok {
|
||||
seen[ch] = struct{}{}
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func (r *PostableRule) GetRuleRouteRequest(ruleID string) ([]*alertmanagertypes.PostableRoutePolicy, error) {
|
||||
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
@@ -185,15 +209,19 @@ func (r *PostableRule) processRuleDefaults() {
|
||||
r.SchemaVersion = DefaultSchemaVersion
|
||||
}
|
||||
|
||||
if r.EvalWindow.IsZero() {
|
||||
r.EvalWindow = valuer.MustParseTextDuration("5m")
|
||||
// v2alpha1 uses the Evaluation envelope for window/frequency;
|
||||
// only default top-level fields for v1.
|
||||
if r.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
if r.EvalWindow.IsZero() {
|
||||
r.EvalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
if r.Frequency.IsZero() {
|
||||
r.Frequency = valuer.MustParseTextDuration("1m")
|
||||
}
|
||||
}
|
||||
|
||||
if r.Frequency.IsZero() {
|
||||
r.Frequency = valuer.MustParseTextDuration("1m")
|
||||
}
|
||||
|
||||
if r.RuleCondition != nil {
|
||||
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
|
||||
switch r.RuleCondition.CompositeQuery.QueryType {
|
||||
case QueryTypeBuilder:
|
||||
if r.RuleType.IsZero() {
|
||||
@@ -259,6 +287,10 @@ func (r *PostableRule) MarshalJSON() ([]byte, error) {
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
@@ -292,23 +324,24 @@ func isValidLabelValue(v string) bool {
|
||||
return utf8.ValidString(v)
|
||||
}
|
||||
|
||||
// validate runs during UnmarshalJSON (read + write path).
|
||||
// Preserves the original pre-existing checks only so that stored rules
|
||||
// continue to load without errors.
|
||||
func (r *PostableRule) validate() error {
|
||||
|
||||
var errs []error
|
||||
|
||||
if r.RuleCondition == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "rule condition is required")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
for k, v := range r.Labels {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
|
||||
}
|
||||
|
||||
if !isValidLabelValue(v) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
|
||||
}
|
||||
@@ -321,7 +354,196 @@ func (r *PostableRule) validate() error {
|
||||
}
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
return errors.Join(errs...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate enforces all validation rules. For now, this is invoked on the write path
|
||||
// (create, update, patch, test) before persisting. This is intentionally
|
||||
// not called from UnmarshalJSON so that existing stored rules can always
|
||||
// be loaded regardless of new validation rules.
|
||||
func (r *PostableRule) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if r.AlertName == "" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alert: field is required"))
|
||||
}
|
||||
|
||||
if r.RuleCondition == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
if r.AlertType != "" {
|
||||
switch r.AlertType {
|
||||
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
|
||||
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
|
||||
}
|
||||
}
|
||||
|
||||
if !r.RuleType.IsZero() {
|
||||
if err := r.RuleType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleType == RuleTypeAnomaly && !r.RuleCondition.Seasonality.IsZero() {
|
||||
if err := r.RuleCondition.Seasonality.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.CompositeQuery == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
|
||||
} else {
|
||||
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
|
||||
} else {
|
||||
cq := &qbtypes.CompositeQuery{Queries: r.RuleCondition.CompositeQuery.Queries}
|
||||
if err := cq.Validate(qbtypes.GetValidationOptions(qbtypes.RequestTypeTimeSeries)...); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
|
||||
found := false
|
||||
for _, query := range r.RuleCondition.CompositeQuery.Queries {
|
||||
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.selectedQueryName: %q does not match any query in compositeQuery",
|
||||
r.RuleCondition.SelectedQuery))
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.RequireMinPoints && r.RuleCondition.RequiredNumPoints <= 0 {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.requiredNumPoints: must be greater than 0 when requireMinPoints is enabled"))
|
||||
}
|
||||
|
||||
errs = append(errs, r.validateSchemaVersion()...)
|
||||
|
||||
for k, v := range r.Labels {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
|
||||
}
|
||||
if !isValidLabelValue(v) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
|
||||
}
|
||||
}
|
||||
|
||||
for k := range r.Annotations {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid annotation name: %s", k))
|
||||
}
|
||||
}
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateSchemaVersion() []error {
|
||||
switch r.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
return r.validateV1()
|
||||
case SchemaVersionV2Alpha1:
|
||||
return r.validateV2Alpha1()
|
||||
default:
|
||||
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"schemaVersion: unsupported value %q; must be one of %q, %q",
|
||||
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV1() []error {
|
||||
var errs []error
|
||||
|
||||
if r.RuleCondition.Target == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
}
|
||||
if r.RuleCondition.CompareOperator.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if r.RuleCondition.MatchType.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV2Alpha1() []error {
|
||||
var errs []error
|
||||
|
||||
// TODO(srikanthccv): reject v1-only fields?
|
||||
// if r.RuleCondition.Target != nil {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.CompareOperator.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.MatchType.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if len(r.PreferredChannels) > 0 {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
|
||||
// Require v2alpha1-specific fields
|
||||
if r.RuleCondition.Thresholds == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
}
|
||||
|
||||
if r.Evaluation == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"evaluation: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
}
|
||||
if r.NotificationSettings == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
} else {
|
||||
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func testTemplateParsing(rl *PostableRule) (errs []error) {
|
||||
@@ -393,6 +615,10 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
|
||||
@@ -34,15 +34,15 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
@@ -77,14 +77,15 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 5.0,
|
||||
"matchType": "1",
|
||||
@@ -112,12 +113,14 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
"promQueries": {
|
||||
"A": {
|
||||
"queries": [{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "rate(http_requests_total[5m])",
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
@@ -165,12 +168,13 @@ func TestParseIntoRule(t *testing.T) {
|
||||
|
||||
func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initRule PostableRule
|
||||
content []byte
|
||||
kind RuleDataKind
|
||||
expectError bool
|
||||
validate func(*testing.T, *PostableRule)
|
||||
name string
|
||||
initRule PostableRule
|
||||
content []byte
|
||||
kind RuleDataKind
|
||||
expectError bool // unmarshal error (read path)
|
||||
expectValidateError bool // Validate() error (write path only)
|
||||
validate func(*testing.T, *PostableRule)
|
||||
}{
|
||||
{
|
||||
name: "schema v1 - threshold name from severity label",
|
||||
@@ -182,13 +186,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
},
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 85.0,
|
||||
@@ -271,13 +277,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "memory_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
@@ -312,13 +320,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
},
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 80.0,
|
||||
@@ -394,49 +404,253 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v2 - does not populate thresholds and evaluation",
|
||||
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "V2Test",
|
||||
"schemaVersion": "v2",
|
||||
"alert": "V2Alpha1Test",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion)
|
||||
}
|
||||
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Error("Expected Thresholds to be present for v2alpha1")
|
||||
}
|
||||
if rule.Evaluation == nil {
|
||||
t.Error("Expected Evaluation to be present for v2alpha1")
|
||||
}
|
||||
if rule.NotificationSettings == nil {
|
||||
t.Error("Expected NotificationSettings to be present for v2alpha1")
|
||||
}
|
||||
if rule.RuleType != RuleTypeThreshold {
|
||||
t.Error("Expected RuleType to be auto-detected")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - rejects v1-only fields with suggestions",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MixedFieldsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"preferredChannels": ["slack"],
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion != "v2" {
|
||||
t.Errorf("Expected schemaVersion 'v2', got '%s'", rule.SchemaVersion)
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires evaluation",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingEvalTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
|
||||
if rule.RuleCondition.Thresholds != nil {
|
||||
t.Error("Expected Thresholds to be nil for v2")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires notificationSettings",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingNotifTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
}
|
||||
if rule.Evaluation != nil {
|
||||
t.Error("Expected Evaluation to be nil for v2")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires thresholds for non-promql rules",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingThresholdsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
|
||||
if rule.EvalWindow.Duration() != 5*time.Minute {
|
||||
t.Error("Expected default EvalWindow to be applied")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported schema version",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "BadSchemaTest",
|
||||
"schemaVersion": "v3",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
if rule.RuleType != RuleTypeThreshold {
|
||||
t.Error("Expected RuleType to be auto-detected")
|
||||
}
|
||||
},
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "default schema version - defaults to v1 behavior",
|
||||
@@ -447,13 +661,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 75.0,
|
||||
"matchType": "1",
|
||||
@@ -480,13 +696,23 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rule := tt.initRule
|
||||
err := json.Unmarshal(tt.content, &rule)
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected unmarshal error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected unmarshal error: %v", err)
|
||||
return
|
||||
}
|
||||
if tt.validate != nil && err == nil {
|
||||
if tt.expectValidateError {
|
||||
if err := rule.Validate(); err == nil {
|
||||
t.Errorf("Expected Validate() error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, &rule)
|
||||
}
|
||||
})
|
||||
@@ -500,15 +726,15 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "response_time"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "response_time", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
@@ -571,7 +797,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
|
||||
func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"schemaVersion": "v2",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"alert": "MultiThresholdAlert",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
@@ -579,19 +805,16 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"unit": "%",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A",
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
@@ -616,6 +839,20 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
rule := PostableRule{}
|
||||
|
||||
@@ -54,6 +54,29 @@ func (CompareOperator) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns the canonical (numeric) form of the operator.
|
||||
// This ensures evaluation logic can use simple == checks against the canonical values.
|
||||
func (c CompareOperator) Normalize() CompareOperator {
|
||||
switch c {
|
||||
case ValueIsAbove, ValueIsAboveLiteral, ValueIsAboveSymbol:
|
||||
return ValueIsAbove
|
||||
case ValueIsBelow, ValueIsBelowLiteral, ValueIsBelowSymbol:
|
||||
return ValueIsBelow
|
||||
case ValueIsEq, ValueIsEqLiteral, ValueIsEqLiteralShort, ValueIsEqSymbol:
|
||||
return ValueIsEq
|
||||
case ValueIsNotEq, ValueIsNotEqLiteral, ValueIsNotEqLiteralShort, ValueIsNotEqSymbol:
|
||||
return ValueIsNotEq
|
||||
case ValueAboveOrEq, ValueAboveOrEqLiteral, ValueAboveOrEqLiteralShort, ValueAboveOrEqSymbol:
|
||||
return ValueAboveOrEq
|
||||
case ValueBelowOrEq, ValueBelowOrEqLiteral, ValueBelowOrEqLiteralShort, ValueBelowOrEqSymbol:
|
||||
return ValueBelowOrEq
|
||||
case ValueOutsideBounds, ValueOutsideBoundsLiteral:
|
||||
return ValueOutsideBounds
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
func (c CompareOperator) Validate() error {
|
||||
switch c {
|
||||
case ValueIsAbove,
|
||||
@@ -70,10 +93,18 @@ func (c CompareOperator) Validate() error {
|
||||
ValueIsNotEqLiteral,
|
||||
ValueIsNotEqLiteralShort,
|
||||
ValueIsNotEqSymbol,
|
||||
ValueAboveOrEq,
|
||||
ValueAboveOrEqLiteral,
|
||||
ValueAboveOrEqLiteralShort,
|
||||
ValueAboveOrEqSymbol,
|
||||
ValueBelowOrEq,
|
||||
ValueBelowOrEqLiteral,
|
||||
ValueBelowOrEqLiteralShort,
|
||||
ValueBelowOrEqSymbol,
|
||||
ValueOutsideBounds,
|
||||
ValueOutsideBoundsLiteral:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown comparison operator, known values are: ")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q; must be one of above, below, equal, not_equal, above_or_equal, below_or_equal, outside_bounds", c.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type MatchType struct {
|
||||
|
||||
var (
|
||||
AtleastOnce = MatchType{valuer.NewString("1")}
|
||||
AtleastOnceLiteral = MatchType{valuer.NewString("atleast_once")}
|
||||
AtleastOnceLiteral = MatchType{valuer.NewString("at_least_once")}
|
||||
|
||||
AllTheTimes = MatchType{valuer.NewString("2")}
|
||||
AllTheTimesLiteral = MatchType{valuer.NewString("all_the_times")}
|
||||
@@ -38,6 +38,24 @@ func (MatchType) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns the canonical (numeric) form of the match type.
|
||||
func (m MatchType) Normalize() MatchType {
|
||||
switch m {
|
||||
case AtleastOnce, AtleastOnceLiteral:
|
||||
return AtleastOnce
|
||||
case AllTheTimes, AllTheTimesLiteral:
|
||||
return AllTheTimes
|
||||
case OnAverage, OnAverageLiteral, OnAverageShort:
|
||||
return OnAverage
|
||||
case InTotal, InTotalLiteral, InTotalShort:
|
||||
return InTotal
|
||||
case Last, LastLiteral:
|
||||
return Last
|
||||
default:
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func (m MatchType) Validate() error {
|
||||
switch m {
|
||||
case
|
||||
@@ -55,6 +73,6 @@ func (m MatchType) Validate() error {
|
||||
LastLiteral:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown match type operator, known values are")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q; must be one of at_least_once, all_the_times, on_average, in_total, last", m.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ func (r RuleType) Validate() error {
|
||||
RuleTypeAnomaly:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown rule type, known values are")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ruleType: unsupported value %q; must be one of threshold_rule, promql_rule, anomaly_rule", r.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
35
pkg/types/ruletypes/seasonality.go
Normal file
35
pkg/types/ruletypes/seasonality.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Seasonality struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SeasonalityHourly = Seasonality{valuer.NewString("hourly")}
|
||||
SeasonalityDaily = Seasonality{valuer.NewString("daily")}
|
||||
SeasonalityWeekly = Seasonality{valuer.NewString("weekly")}
|
||||
)
|
||||
|
||||
func (Seasonality) Enum() []any {
|
||||
return []any{
|
||||
SeasonalityHourly,
|
||||
SeasonalityDaily,
|
||||
SeasonalityWeekly,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Seasonality) Validate() error {
|
||||
switch s {
|
||||
case SeasonalityHourly, SeasonalityDaily, SeasonalityWeekly:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.seasonality: unsupported value %q; must be one of hourly, daily, weekly",
|
||||
s.StringValue())
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,9 @@ func (r BasicRuleThresholds) GetRuleReceivers() []RuleReceivers {
|
||||
}
|
||||
|
||||
func (r BasicRuleThresholds) Validate() error {
|
||||
if len(r) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: must have at least one threshold")
|
||||
}
|
||||
var errs []error
|
||||
for _, basicThreshold := range r {
|
||||
if err := basicThreshold.Validate(); err != nil {
|
||||
@@ -189,7 +192,7 @@ func sortThresholds(thresholds []BasicRuleThreshold) {
|
||||
targetI := thresholds[i].target(thresholds[i].TargetUnit) //for sorting we dont need rule unit
|
||||
targetJ := thresholds[j].target(thresholds[j].TargetUnit)
|
||||
|
||||
switch thresholds[i].CompareOperator {
|
||||
switch thresholds[i].CompareOperator.Normalize() {
|
||||
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
|
||||
// For "above" operations, sort descending (higher values first)
|
||||
return targetI > targetJ
|
||||
@@ -234,16 +237,11 @@ func (b BasicRuleThreshold) Validate() error {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "target value cannot be nil"))
|
||||
}
|
||||
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove, ValueIsBelow, ValueIsEq, ValueIsNotEq, ValueAboveOrEq, ValueBelowOrEq, ValueOutsideBounds:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid compare operation: %s", b.CompareOperator.StringValue()))
|
||||
if err := b.CompareOperator.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
switch b.MatchType {
|
||||
case AtleastOnce, AllTheTimes, OnAverage, InTotal, Last:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid match type: %s", b.MatchType.StringValue()))
|
||||
if err := b.MatchType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
@@ -268,6 +266,33 @@ func PrepareSampleLabelsForRule(seriesLabels []*qbtypes.Label, thresholdName str
|
||||
return lb.Labels()
|
||||
}
|
||||
|
||||
// matchesCompareOp checks if a value matches the compare operator against target.
|
||||
func matchesCompareOp(op CompareOperator, value, target float64) bool {
|
||||
switch op {
|
||||
case ValueIsAbove:
|
||||
return value > target
|
||||
case ValueIsBelow:
|
||||
return value < target
|
||||
case ValueIsEq:
|
||||
return value == target
|
||||
case ValueIsNotEq:
|
||||
return value != target
|
||||
case ValueAboveOrEq:
|
||||
return value >= target
|
||||
case ValueBelowOrEq:
|
||||
return value <= target
|
||||
case ValueOutsideBounds:
|
||||
return math.Abs(value) >= target
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// negatesCompareOp checks if a value does NOT match the compare operator against target.
|
||||
func negatesCompareOp(op CompareOperator, value, target float64) bool {
|
||||
return !matchesCompareOp(op, value, target)
|
||||
}
|
||||
|
||||
func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, target float64) (Sample, bool) {
|
||||
var shouldAlert bool
|
||||
var alertSmpl Sample
|
||||
@@ -278,63 +303,35 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
return alertSmpl, false
|
||||
}
|
||||
|
||||
switch b.MatchType {
|
||||
// Normalize to canonical forms so evaluation uses simple == checks
|
||||
op := b.CompareOperator.Normalize()
|
||||
matchType := b.MatchType.Normalize()
|
||||
|
||||
switch matchType {
|
||||
case AtleastOnce:
|
||||
// If any sample matches the condition, the rule is firing.
|
||||
if b.CompareOperator == ValueIsAbove {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value > target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsBelow {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value < target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value == target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsNotEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value != target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueOutsideBounds {
|
||||
for _, smpl := range series.Values {
|
||||
if math.Abs(smpl.Value) >= target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
for _, smpl := range series.Values {
|
||||
if matchesCompareOp(op, smpl.Value, target) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case AllTheTimes:
|
||||
// If all samples match the condition, the rule is firing.
|
||||
shouldAlert = true
|
||||
alertSmpl = Sample{Point: Point{V: target}, Metric: lbls}
|
||||
if b.CompareOperator == ValueIsAbove {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value <= target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
for _, smpl := range series.Values {
|
||||
if negatesCompareOp(op, smpl.Value, target) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
// use min value from the series
|
||||
if shouldAlert {
|
||||
}
|
||||
if shouldAlert {
|
||||
switch op {
|
||||
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
|
||||
// use min value from the series
|
||||
var minValue = math.Inf(1)
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value < minValue {
|
||||
@@ -342,15 +339,8 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: minValue}, Metric: lbls}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsBelow {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value >= target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldAlert {
|
||||
case ValueIsBelow, ValueBelowOrEq:
|
||||
// use max value from the series
|
||||
var maxValue = math.Inf(-1)
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value > maxValue {
|
||||
@@ -358,23 +348,8 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lbls}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value != target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsNotEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value == target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// use any non-inf or nan value from the series
|
||||
if shouldAlert {
|
||||
case ValueIsNotEq:
|
||||
// use any non-inf and non-nan value from the series
|
||||
for _, smpl := range series.Values {
|
||||
if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
@@ -382,14 +357,6 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueOutsideBounds {
|
||||
for _, smpl := range series.Values {
|
||||
if math.Abs(smpl.Value) < target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case OnAverage:
|
||||
// If the average of all samples matches the condition, the rule is firing.
|
||||
@@ -403,32 +370,10 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
avg := sum / count
|
||||
alertSmpl = Sample{Point: Point{V: avg}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if avg > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if avg < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if avg == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if avg != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueOutsideBounds:
|
||||
if math.Abs(avg) >= target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
shouldAlert = matchesCompareOp(op, avg, target)
|
||||
case InTotal:
|
||||
// If the sum of all samples matches the condition, the rule is firing.
|
||||
var sum float64
|
||||
|
||||
for _, smpl := range series.Values {
|
||||
if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) {
|
||||
continue
|
||||
@@ -436,50 +381,12 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
sum += smpl.Value
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: sum}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if sum > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if sum < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if sum == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if sum != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueOutsideBounds:
|
||||
if math.Abs(sum) >= target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
shouldAlert = matchesCompareOp(op, sum, target)
|
||||
case Last:
|
||||
// If the last sample matches the condition, the rule is firing.
|
||||
shouldAlert = false
|
||||
alertSmpl = Sample{Point: Point{V: series.Values[len(series.Values)-1].Value}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if series.Values[len(series.Values)-1].Value > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if series.Values[len(series.Values)-1].Value < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if series.Values[len(series.Values)-1].Value == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if series.Values[len(series.Values)-1].Value != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
lastValue := series.Values[len(series.Values)-1].Value
|
||||
alertSmpl = Sample{Point: Point{V: lastValue}, Metric: lbls}
|
||||
shouldAlert = matchesCompareOp(op, lastValue, target)
|
||||
}
|
||||
return alertSmpl, shouldAlert
|
||||
}
|
||||
|
||||
1720
pkg/types/ruletypes/validate_test.go
Normal file
1720
pkg/types/ruletypes/validate_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user