Compare commits

..

34 Commits

Author SHA1 Message Date
Jatinderjit Singh
d61781a512 feat(planned-downtime): explicit toggle for all vs specific alert rules
Replace the implicit "empty alert list silences everything" behavior
with a Radio toggle ("All alert rules" / "Specific alert rules") so
users can't accidentally silence every alert by forgetting to select
rules. The list view now displays an explicit "All alert rules" tag
instead of a dash for schedules that silence everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:41:36 +05:30
Jatinderjit Singh
494552c530 remove unused function evaluateLabelExpression 2026-05-19 17:41:36 +05:30
Jatinderjit Singh
f7e7485f97 fix(tests): resolve envprovider env isolation, factory name length, and ShouldSkip signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:41:36 +05:30
Jatinderjit Singh
493b337494 fix lset type and update openapi spec 2026-05-19 17:41:36 +05:30
Jatinderjit Singh
8dc458e6ba implement Down migration to drop label_expression column 2026-05-19 17:41:36 +05:30
Jatinderjit Singh
e7176de589 remove redundant LabelSet->map conversion 2026-05-19 17:41:36 +05:30
Jatinderjit Singh
0500388d4c Move label expression evaluation into ShouldSkip
ShouldSkip now owns all three suppression checks in sequence:
rule ID match → schedule active → label expression.
IsActive passes nil labels so the expression check is skipped
(no instance labels available for UI status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:41:36 +05:30
Jatinderjit Singh
d2010b01ab Remove redundant || undefined from labelExpression assignment 2026-05-19 17:41:36 +05:30
Jatinderjit Singh
3929138c87 Add label expression support to planned downtime
Alert instances can now be scoped by label expression
(e.g. env == "prod"), scoping suppression below the rule level.
A window with no rule IDs and a label expression silences any
alert whose labels match, regardless of which rule fired it;
when rule IDs are also present, the expression is evaluated only
within the matched rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:41:36 +05:30
Jatinderjit Singh
f08782adeb test: add e2e muting tests for maintenance window behaviour 2026-05-19 17:37:06 +05:30
Jatinderjit Singh
b81f0dc8e5 cleanup test 2026-05-19 16:38:21 +05:30
Jatinderjit Singh
a97feaceb0 chore: regenerate mocks via make gen-mocks
Picks up new MockHandler for the Handler interface in pkg/alertmanager
and regenerates MockMaintenanceStore with canonical mockery formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:25:17 +05:30
Jatinderjit Singh
165218dedb test: use mockery-generated mock for MaintenanceStore in muter tests
Replace hand-written fakeMaintenanceStore with a mockery-generated
MockMaintenanceStore, consistent with the alertmanagertest pattern.
Also adds MaintenanceStore to .mockery.yml so the mock stays in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:11:48 +05:30
Jatinderjit Singh
6ee3f75bf6 Go lint fixes 2026-05-19 15:02:47 +05:30
Jatinderjit Singh
32e5bf2f17 fix NewMaintenanceStore in tests 2026-05-19 14:51:18 +05:30
Jatinderjit Singh
dbebb76bda Re-add marker 2026-05-19 14:29:26 +05:30
Jatinderjit Singh
13547f29e4 Update schema changes 2026-05-19 14:10:03 +05:30
Jatinderjit Singh
cd3e4bcb87 test: add unit tests for MaintenanceMuter
Covers Mutes/MutedBy semantics (empty label, rule match, empty-RuleIDs
matches-all, future windows, multi-window) and the result cache
(single-fetch within TTL, stale-cache fallback on store error,
re-fetch after expiry, concurrency safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:57:04 +05:30
Jatinderjit Singh
b5ae86c3f7 refactor: move maintenance (planned downtime) to alertmanager packages
Types move from pkg/types/ruletypes/ to pkg/types/alertmanagertypes/:
- maintenance.go, recurrence.go, schedule.go (+ tests)

Store impl moves from pkg/ruler/rulestore/sqlrulestore/ to
pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore/.

Maintenance windows mute alerts, so they belong with alertmanager
rather than the rule types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:57:04 +05:30
Jatinderjit Singh
6143b9bac2 code cleanup 2026-05-19 13:53:29 +05:30
Jatinderjit Singh
09ac78b42e feat: surface maintenance-suppressed alerts via mutedBy in GetAlerts
Alerts suppressed by an active maintenance window were being correctly
muted in the notification pipeline but appeared as state=active in the
v2 GetAlerts response, since MaintenanceMuter.Mutes had no marker
side-effect (unlike inhibitor/silencer).

Add MaintenanceMuter.MutedBy returning the matching window IDs, and
plumb a mutedByFunc callback through NewGettableAlertsFromAlertProvider
into AlertToOpenAPIAlert. The upstream v2 API forces state=suppressed
when mutedBy is non-empty, so the frontend's existing state-based
rendering picks it up without further changes.

Use the dedicated mutedBy field rather than SilencedBy to avoid
violating the "complete set of silence IDs" contract that anything
querying silences by ID would rely on.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
84ca7c0bd9 remove redundant MemMarker wrapper 2026-05-19 13:53:29 +05:30
Jatinderjit Singh
08c763ba0a refactor: move MaintenanceMuter to Server and pass it to pipelineBuilder.New
- Remove muter from pipelineBuilder struct and newPipelineBuilder();
  pass it as a parameter to New() instead, consistent with inhibitor/silencer
- Store muter on Server so GetAlerts can call Mutes() alongside the
  inhibitor and silencer, ensuring maintenance-suppressed alerts show
  the correct muted status in API responses

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
27182a0275 refactor: always initialize maintenanceStore; remove nil guards
Tests now use a real sqlrulestore-backed MaintenanceMuter instead of
passing nil. With nil no longer a valid input, remove the nil guards
in server.go and pipeline_builder.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
2a59ec62ca refactor: hoist MuteStage construction out of the receiver loop
MuteStage holds no per-receiver state, so one instance shared across
all receivers is sufficient — matching how is/ss are handled upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
ec07c80e70 refactor: replace maintenanceMuteStage with notify.NewMuteStage
MaintenanceMuter already satisfies types.Muter, and pipelineBuilder has
its own pb.metrics, so the hand-rolled maintenanceMuteStage wrapper is
redundant. Use notify.NewMuteStage(pb.muter, pb.metrics) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
32747dcb52 rename buildReceiverStage -> createReceiverStage 2026-05-19 13:53:29 +05:30
Jatinderjit Singh
b517e97612 refactor: remove dead orgID param from task constructors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
7b99f4475c refactor: pass MaintenanceMuter directly to pipelineBuilder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
7a8826531e chore: replace SPDX tag with full Apache 2.0 license boilerplate
The full license text is unambiguously compliant with Apache 2.0 Section 4(a),
which requires giving recipients "a copy of this License".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
6ca316df01 chore: add license header to pipeline_builder.go
Copied code originates from Apache-2.0 licensed Prometheus Alertmanager;
add dual copyright + SPDX identifier following the repo's convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
e5ba44f257 refactor: move maintenance mute stage into custom pipelineBuilder
Copy notify.PipelineBuilder locally so we can inject mms between the
silence stage and the receiver stage (GossipSettle → Inhibit →
TimeActive → TimeMute → Silence → mms → Receiver), matching the
correct suppression order the team requires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
49a6f879a2 refactor: wrap routing pipeline once instead of per-route injection
Replace the per-route-entry loop with a single MultiStage wrap so
maintenance suppression runs once per dispatch group before routing.
2026-05-19 13:53:29 +05:30
Jatinderjit Singh
4127705a0c add maintenanceMuteStage to move planned maintenance to alertmanager
Rules previously skipped rule.Eval() entirely during maintenance windows.
This change moves suppression to MaintenanceMuter, injected as a Stage
in the alertmanager notification pipeline. Now rules always evaluate and
everys suppression is handled by alertmanager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:53:29 +05:30
184 changed files with 2143 additions and 7917 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.125.1
image: signoz/signoz:v0.124.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.125.1
image: signoz/signoz:v0.124.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.125.1}
image: signoz/signoz:${VERSION:-v0.124.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.125.1}
image: signoz/signoz:${VERSION:-v0.124.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -125,6 +125,8 @@ components:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceKind'
labelExpression:
type: string
name:
type: string
schedule:
@@ -268,6 +270,8 @@ components:
type: array
description:
type: string
labelExpression:
type: string
name:
type: string
schedule:
@@ -2342,8 +2346,6 @@ components:
type: boolean
org_id:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
updatedAt:
format: date-time
type: string
@@ -2373,12 +2375,6 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesSource:
enum:
- user
- system
- integration
type: object
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
@@ -2689,6 +2685,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2758,6 +2755,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2827,6 +2825,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2905,6 +2904,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2980,6 +2980,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3027,6 +3028,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3104,6 +3106,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3202,6 +3205,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3546,6 +3550,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3606,6 +3611,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'

View File

@@ -49,14 +49,6 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
storablePublicDashboard, err := module.store.GetPublic(ctx, publicDashboard.DashboardID.StringValue())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
@@ -137,14 +129,6 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
}
@@ -154,10 +138,6 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
if err := dashboard.ErrIfNotDeletable(); err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}
@@ -188,14 +168,6 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, dashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err

View File

@@ -34,7 +34,7 @@ export default defineConfig({
signal: true,
useOperationIdAsQueryKey: false,
},
useDates: false,
useDates: true,
useNamedParameters: true,
enumGenerationType: 'enum',
mutator: {

View File

@@ -15,7 +15,6 @@
const BANNED_COMPONENTS = {
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
};
export default {

View File

@@ -47,6 +47,7 @@ export const TracesFunnels = Loadable(
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
);
export const TracesFunnelDetails = Loadable(
// eslint-disable-next-line sonarjs/no-identical-functions
() =>
import(
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
@@ -312,6 +313,13 @@ export const PublicDashboardPage = Loadable(
),
);
export const AlertTypeSelectionPage = Loadable(
() =>
import(
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
),
);
export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),

View File

@@ -5,6 +5,7 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
AllAlertChannels,
AllErrors,
ApiMonitoring,
@@ -212,6 +213,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERT_TYPE_SELECTION,
exact: true,
component: AlertTypeSelectionPage,
isPrivate: true,
key: 'ALERT_TYPE_SELECTION',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
@@ -525,6 +533,18 @@ export const LIST_LICENSES: AppRoutes = {
key: 'LIST_LICENSES',
};
export const oldRoutes = [
'/pipelines',
'/logs-explorer',
'/logs-explorer/live',
'/logs-save-views',
'/traces-save-views',
'/settings/access-tokens',
'/settings/api-keys',
'/messaging-queues',
'/alerts/edit',
];
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
@@ -535,9 +555,7 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);
export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
ROUTES.WORKSPACE_LOCKED,

View File

@@ -9,7 +9,7 @@ export interface AlertmanagertypesChannelDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -34,7 +34,7 @@ export interface AlertmanagertypesChannelDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ModelLabelSetDTO {
@@ -62,7 +62,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
endsAt?: string;
endsAt?: Date;
/**
* @type string
*/
@@ -80,7 +80,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
startsAt?: string;
startsAt?: Date;
status?: TypesAlertStatusDTO;
}
@@ -97,7 +97,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
createdAt: string;
createdAt: Date;
/**
* @type string,null
*/
@@ -127,7 +127,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
updatedAt: string;
updatedAt: Date;
/**
* @type string,null
*/
@@ -166,7 +166,7 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string,null
* @format date-time
*/
endTime?: string | null;
endTime?: Date | null;
/**
* @type array,null
*/
@@ -176,7 +176,7 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
* @format date-time
*/
startTime: string;
startTime: Date;
}
export interface AlertmanagertypesScheduleDTO {
@@ -184,13 +184,13 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
endTime?: string;
endTime?: Date;
recurrence?: AlertmanagertypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: string;
startTime?: Date;
/**
* @type string
*/
@@ -206,7 +206,7 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -220,6 +220,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
*/
id: string;
kind: AlertmanagertypesMaintenanceKindDTO;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -230,7 +234,7 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -1709,6 +1713,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
* @type string
*/
description?: string;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -1953,7 +1961,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -1970,7 +1978,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface AuthtypesGettableTokenDTO {
@@ -2128,7 +2136,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -2153,7 +2161,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface AuthtypesSessionContextDTO {
@@ -2181,7 +2189,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt: string;
createdAt: Date;
/**
* @type string
*/
@@ -2195,7 +2203,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
updatedAt: string;
updatedAt: Date;
/**
* @type string
*/
@@ -2207,7 +2215,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -2236,7 +2244,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type array,null
*/
@@ -2403,7 +2411,7 @@ export interface CloudintegrationtypesAccountDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -2424,12 +2432,12 @@ export interface CloudintegrationtypesAccountDTO {
* @type string,null
* @format date-time
*/
removedAt: string | null;
removedAt: Date | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface DashboardtypesStorableDashboardDataDTO {
@@ -2560,7 +2568,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -2570,7 +2578,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
};
/**
@@ -2764,12 +2772,12 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @type string,null
* @format date-time
*/
removed_at: string | null;
removed_at: Date | null;
/**
* @type string,null
* @format date-time
*/
removedAt: string | null;
removedAt: Date | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -2999,17 +3007,12 @@ export interface CoretypesPatchableObjectsDTO {
deletions: CoretypesObjectGroupDTO[] | null;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
integration = 'integration',
}
export interface DashboardtypesDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -3027,12 +3030,11 @@ export interface DashboardtypesDashboardDTO {
* @type string
*/
org_id?: string;
source?: DashboardtypesSourceDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -3214,7 +3216,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
created_at?: string;
created_at?: Date;
/**
* @type string
*/
@@ -3236,7 +3238,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
updated_at?: string;
updated_at?: Date;
}
export interface GatewaytypesIngestionKeyDTO {
@@ -3244,12 +3246,12 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
created_at?: string;
created_at?: Date;
/**
* @type string
* @format date-time
*/
expires_at?: string;
expires_at?: Date;
/**
* @type string
*/
@@ -3270,7 +3272,7 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
updated_at?: string;
updated_at?: Date;
/**
* @type string
*/
@@ -3294,7 +3296,7 @@ export interface GatewaytypesPostableIngestionKeyDTO {
* @type string
* @format date-time
*/
expires_at?: string;
expires_at?: Date;
/**
* @type string
*/
@@ -3488,9 +3490,9 @@ export interface InframonitoringtypesClustersDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesClusterRecordDTO[];
records: InframonitoringtypesClusterRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3566,9 +3568,9 @@ export interface InframonitoringtypesDaemonSetsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesDaemonSetRecordDTO[];
records: InframonitoringtypesDaemonSetRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3644,9 +3646,9 @@ export interface InframonitoringtypesDeploymentsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesDeploymentRecordDTO[];
records: InframonitoringtypesDeploymentRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3730,9 +3732,9 @@ export interface InframonitoringtypesHostsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesHostRecordDTO[];
records: InframonitoringtypesHostRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3816,9 +3818,9 @@ export interface InframonitoringtypesJobsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesJobRecordDTO[];
records: InframonitoringtypesJobRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3866,9 +3868,9 @@ export interface InframonitoringtypesNamespacesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesNamespaceRecordDTO[];
records: InframonitoringtypesNamespaceRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3933,9 +3935,9 @@ export interface InframonitoringtypesNodesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesNodeRecordDTO[];
records: InframonitoringtypesNodeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4017,9 +4019,9 @@ export interface InframonitoringtypesPodsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesPodRecordDTO[];
records: InframonitoringtypesPodRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4437,9 +4439,9 @@ export interface InframonitoringtypesStatefulSetsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesStatefulSetRecordDTO[];
records: InframonitoringtypesStatefulSetRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4506,9 +4508,9 @@ export interface InframonitoringtypesVolumesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesVolumeRecordDTO[];
records: InframonitoringtypesVolumeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4564,7 +4566,7 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -4603,13 +4605,13 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string,null
* @format date-time
*/
syncedAt?: string | null;
syncedAt?: Date | null;
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -5835,7 +5837,7 @@ export interface Querybuildertypesv5RawRowDTO {
* @type string
* @format date-time
*/
timestamp?: string;
timestamp?: Date;
}
export interface Querybuildertypesv5RawDataDTO {
@@ -6412,7 +6414,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6461,7 +6463,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -6480,7 +6482,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type integer
* @minimum 0
@@ -6494,7 +6496,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
lastObservedAt: string;
lastObservedAt: Date;
/**
* @type string
*/
@@ -6507,7 +6509,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
@@ -6552,7 +6554,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6577,7 +6579,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ServiceaccounttypesServiceAccountRoleDTO {
@@ -6585,7 +6587,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6603,7 +6605,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
@@ -6611,7 +6613,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6640,7 +6642,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
@@ -6682,7 +6684,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6707,7 +6709,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -6776,7 +6778,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -6802,7 +6804,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
/**
* @type string
*/
@@ -7169,7 +7171,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -7202,7 +7204,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface TypesIdentifiableDTO {
@@ -7217,7 +7219,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -7250,7 +7252,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface TypesOrganizationDTO {
@@ -7262,7 +7264,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -7284,7 +7286,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface TypesPostableInviteDTO {
@@ -7351,7 +7353,7 @@ export interface TypesResetPasswordTokenDTO {
* @type string
* @format date-time
*/
expiresAt?: string;
expiresAt?: Date;
/**
* @type string
*/
@@ -7378,7 +7380,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
createdAt?: string;
createdAt?: Date;
/**
* @type string
*/
@@ -7407,7 +7409,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
updatedAt?: string;
updatedAt?: Date;
}
export interface ZeustypesHostDTO {

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,7 +0,0 @@
<svg width="456" height="456" viewBox="0 0 456 456" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="456" height="456" rx="50" fill="#512BD4"/>
<path d="M81.2738 291.333C78.0496 291.333 75.309 290.259 73.052 288.11C70.795 285.906 69.6665 283.289 69.6665 280.259C69.6665 277.173 70.795 274.529 73.052 272.325C75.309 270.121 78.0496 269.019 81.2738 269.019C84.5518 269.019 87.3193 270.121 89.5763 272.325C91.887 274.529 93.0424 277.173 93.0424 280.259C93.0424 283.289 91.887 285.906 89.5763 288.11C87.3193 290.259 84.5518 291.333 81.2738 291.333Z" fill="white"/>
<path d="M210.167 289.515H189.209L133.994 202.406C132.597 200.202 131.441 197.915 130.528 195.546H130.044C130.474 198.081 130.689 203.508 130.689 211.827V289.515H112.149V171H134.477L187.839 256.043C190.096 259.57 191.547 261.994 192.192 263.316H192.514C191.977 260.176 191.708 254.859 191.708 247.365V171H210.167V289.515Z" fill="white"/>
<path d="M300.449 289.515H235.561V171H297.87V187.695H254.746V221.249H294.485V237.861H254.746V272.903H300.449V289.515Z" fill="white"/>
<path d="M392.667 187.695H359.457V289.515H340.272V187.695H307.143V171H392.667V187.695Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
<defs>
<linearGradient id="a" x1="9" y1="17" x2="9" y2="1" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0078d4"/>
<stop offset="1" stop-color="#5ea0ef"/>
</linearGradient>
</defs>
<circle cx="9" cy="9" r="8" fill="url(#a)"/>
<ellipse cx="9" cy="9" rx="3.2" ry="8" fill="none" stroke="#fff" stroke-width=".7"/>
<line x1="1" y1="9" x2="17" y2="9" stroke="#fff" stroke-width=".7"/>
<line x1="2" y1="5.5" x2="16" y2="5.5" stroke="#fff" stroke-width=".5"/>
<line x1="2" y1="12.5" x2="16" y2="12.5" stroke="#fff" stroke-width=".5"/>
<circle cx="9" cy="9" r="8" fill="none" stroke="#fff" stroke-width=".7"/>
<path d="M13.5 10.5l1.5-1.5-1.5-1.5M4.5 10.5L3 9l1.5-1.5" stroke="#50e6ff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 877 B

View File

@@ -1,15 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_0_812)" transform="matrix(1.002103,0,0,1.0377318,6.9399999e-7,-2.5317276e-4)">
<path d="m 141.702,68.418 c 0,7.4632 -4.567,14.1123 -6.748,20.8385 -2.263,6.9789 -2.552,15.0285 -6.776,20.8385 -4.267,5.868 -11.856,8.611 -17.719,12.881 -5.805,4.228 -10.7345,10.628 -17.7061,12.895 -6.7286,2.186 -14.4463,-0.021 -21.9018,-0.021 -7.4555,0 -15.1731,2.207 -21.8998,0.021 C 41.9778,133.604 37.048,127.204 31.2428,122.976 25.3799,118.706 17.7913,115.963 13.5247,110.095 9.30055,104.287 9.01135,96.2374 6.74791,89.2565 4.56351,82.5225 0,75.8735 0,68.418 0,60.9624 4.56737,54.3057 6.74791,47.5795 9.01135,40.6005 9.30055,32.5507 13.5247,26.741 17.7913,20.8753 25.3799,18.1297 31.2428,13.8617 37.048,9.63414 41.9778,3.23209 48.9513,0.966872 55.678,-1.21924 63.3956,0.986167 70.8511,0.986167 c 7.4555,0 15.1732,-2.205407 21.8999,-0.019295 6.9735,2.265218 11.903,8.667268 17.708,12.894828 5.863,4.268 13.452,7.0136 17.719,12.8793 4.224,5.8097 4.513,13.8595 6.776,20.8385 2.181,6.7262 6.748,13.3771 6.748,20.8385 z" fill="#326ce5"/>
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
<path d="m 13.5883,60.53 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 13.5883,68.248 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 13.5883,77.5095 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
</g>
<defs>
<clipPath id="clip0_0_812">
<rect width="141.702" height="136.837" fill="#ffffff"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,55 +0,0 @@
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<g>
<g>
<g>
<rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/>
</g>
</g>
<g>
<g>
<rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/>
</g>
</g>
<path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C373.5,259.9,379.2,281.2,369.5,297.9"/>
<path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C94.8,99,100.5,120.3,90.9,137"/>
<path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C61.4,320.3,40.1,314.6,30.5,297.9"/>
<path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C340.1,159.4,318.7,153.7,309.1,137"/>
<path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,380.1,219.3,395.8,200,395.8"/>
<path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,58.4,219.3,74,200,74"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 77.62745 102.5">
<path fill="#516baa" d="m31.05548,54.44523v24.1773c.00065.04512-.03164.084-.07611.09164l-23.27949,3.99047c-.05091.0076-.09834-.02751-.10594-.07841-.00256-.01712-.0003-.03461.00653-.05051L30.87996,30.58635c.02242-.04633.07815-.06572.12449-.04331.0316.01529.05193.04704.05259.08214l-.00156,23.82005Zm3.92367-13.93321v38.21148c.00046.04691.03573.08617.08232.09164l34.87031,3.89415c.0512.00527.09698-.03196.10226-.08316.00167-.01616-.00092-.03247-.00751-.04732L35.15623,4.70041c-.02237-.04636-.07809-.0658-.12444-.04343-.03117.01504-.05144.04612-.05264.08071v35.77433Zm34.68546,45.76213l-38.57341,11.57218c-.02155.00797-.04524.00797-.06679,0l-23.309-11.57217c-.04636-.0203-.06749-.07435-.04719-.12071.01513-.03455.04988-.0563.08757-.05481h61.88241c.0508.00825.08531.05613.07706.10693-.00482.0297-.02369.05525-.05066.06859Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 901 B

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 776 B

View File

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 590 270">
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M138.66,28.78C107.2,37.87,57.29,43,30.4,43h0V94.35a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.4a129,129,0,0,0,17.49-5.81c4.18-1.88,6.88-3.86,6.88-5.92V15.91C164.1,20.79,151.39,25.11,138.66,28.78Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M138.66,95.37c-18.83,5.43-44.24,9.47-67.39,11.83-15.54,1.59-30.06,2.42-40.87,2.42h0v51.31a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.39a130.38,130.38,0,0,0,17.49-5.81c4.18-1.89,6.88-3.86,6.88-5.92V82.5C164.1,87.37,151.39,91.69,138.66,95.37Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M138.66,162c-18.83,5.43-44.24,9.46-67.39,11.83-15.56,1.59-30.1,2.42-40.91,2.42V228c18.16,0,75.1-5.95,109.37-15.39,12.63-3.48,24.37-7.44,24.37-11.74V149.08C164.1,154,151.39,158.28,138.66,162Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="currentColor"/>
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="currentColor"/>
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="currentColor"/>
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="currentColor"/>
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,37 +0,0 @@
<svg viewBox="0 0 254.5 225" xmlns="http://www.w3.org/2000/svg">
<g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M40.2,101.1c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l35.7,0c0.4,0,0.5,0.3,0.3,0.6 l-1.7,2.6c-0.2,0.3-0.7,0.6-1,0.6L40.2,101.1z"/>
</g>
</g>
</g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M25.1,110.3c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l45.6,0c0.4,0,0.6,0.3,0.5,0.6 l-0.8,2.4c-0.1,0.4-0.5,0.6-0.9,0.6L25.1,110.3z"/>
</g>
</g>
</g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M49.3,119.5c-0.4,0-0.5-0.3-0.3-0.6l1.4-2.5c0.2-0.3,0.6-0.6,1-0.6l20,0c0.4,0,0.6,0.3,0.6,0.7l-0.2,2.4 c0,0.4-0.4,0.7-0.7,0.7L49.3,119.5z"/>
</g>
</g>
</g>
<g>
<g id="CXHf1q_3_">
<g>
<g>
<path fill="#00ACD7" d="M153.1,99.3c-6.3,1.6-10.6,2.8-16.8,4.4c-1.5,0.4-1.6,0.5-2.9-1c-1.5-1.7-2.6-2.8-4.7-3.8 c-6.3-3.1-12.4-2.2-18.1,1.5c-6.8,4.4-10.3,10.9-10.2,19c0.1,8,5.6,14.6,13.5,15.7c6.8,0.9,12.5-1.5,17-6.6 c0.9-1.1,1.7-2.3,2.7-3.7c-3.6,0-8.1,0-19.3,0c-2.1,0-2.6-1.3-1.9-3c1.3-3.1,3.7-8.3,5.1-10.9c0.3-0.6,1-1.6,2.5-1.6 c5.1,0,23.9,0,36.4,0c-0.2,2.7-0.2,5.4-0.6,8.1c-1.1,7.2-3.8,13.8-8.2,19.6c-7.2,9.5-16.6,15.4-28.5,17 c-9.8,1.3-18.9-0.6-26.9-6.6c-7.4-5.6-11.6-13-12.7-22.2c-1.3-10.9,1.9-20.7,8.5-29.3c7.1-9.3,16.5-15.2,28-17.3 c9.4-1.7,18.4-0.6,26.5,4.9c5.3,3.5,9.1,8.3,11.6,14.1C154.7,98.5,154.3,99,153.1,99.3z"/>
</g>
<g>
<path fill="#00ACD7" d="M186.2,154.6c-9.1-0.2-17.4-2.8-24.4-8.8c-5.9-5.1-9.6-11.6-10.8-19.3c-1.8-11.3,1.3-21.3,8.1-30.2 c7.3-9.6,16.1-14.6,28-16.7c10.2-1.8,19.8-0.8,28.5,5.1c7.9,5.4,12.8,12.7,14.1,22.3c1.7,13.5-2.2,24.5-11.5,33.9 c-6.6,6.7-14.7,10.9-24,12.8C191.5,154.2,188.8,154.3,186.2,154.6z M210,114.2c-0.1-1.3-0.1-2.3-0.3-3.3 c-1.8-9.9-10.9-15.5-20.4-13.3c-9.3,2.1-15.3,8-17.5,17.4c-1.8,7.8,2,15.7,9.2,18.9c5.5,2.4,11,2.1,16.3-0.6 C205.2,129.2,209.5,122.8,210,114.2z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,17 +0,0 @@
.breadcrumb {
padding-left: 16px;
ol {
align-items: center;
}
:global(.ant-breadcrumb-separator) {
color: var(--muted-foreground);
}
}
.divider {
border-color: var(--l1-border);
margin: 16px 0;
margin-top: 10px;
}

View File

@@ -1,32 +0,0 @@
import { Breadcrumb, Divider } from 'antd';
import styles from './AlertBreadcrumb.module.scss';
import BreadcrumbItem, { BreadcrumbItemConfig } from './BreadcrumbItem';
export interface AlertBreadcrumbProps {
items: BreadcrumbItemConfig[];
className?: string;
showDivider?: boolean;
}
function AlertBreadcrumb({
items,
className,
showDivider = true,
}: AlertBreadcrumbProps): JSX.Element {
const breadcrumbItems = items.map((item) => ({
title: <BreadcrumbItem {...item} />,
}));
return (
<>
<Breadcrumb
className={`${styles.breadcrumb} ${className || ''}`}
items={breadcrumbItems}
/>
{showDivider && <Divider className={styles.divider} />}
</>
);
}
export default AlertBreadcrumb;

View File

@@ -1,9 +0,0 @@
.item {
--button-padding: 0;
--button-font-size: var(--periscope-font-size-base);
}
.itemLast {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,45 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BreadcrumbItem.module.scss';
export type BreadcrumbItemConfig =
| {
title: string | null;
route?: string;
}
| {
title: string | null;
isLast?: true;
};
function BreadcrumbItem({
title,
...props
}: BreadcrumbItemConfig): JSX.Element {
const { safeNavigate } = useSafeNavigate();
if ('isLast' in props) {
return <div className={styles.itemLast}>{title}</div>;
}
return (
<Button
variant="ghost"
color="secondary"
className={styles.item}
onClick={(e: React.MouseEvent): void => {
if (!('route' in props) || !props.route) {
return;
}
safeNavigate(props.route, { newTab: isModifierKeyPressed(e) });
}}
>
{title}
</Button>
);
}
export default BreadcrumbItem;

View File

@@ -1,6 +0,0 @@
export { default } from './AlertBreadcrumb';
export {
default as BreadcrumbItem,
type BreadcrumbItemConfig,
} from './BreadcrumbItem';
export type { AlertBreadcrumbProps } from './AlertBreadcrumb';

View File

@@ -59,7 +59,7 @@ function getDeleteTooltip(
function getInviteButtonLabel(
isLoading: boolean,
existingToken: { expiresAt?: string } | undefined,
existingToken: { expiresAt?: Date } | undefined,
isExpired: boolean,
notFound: boolean,
): string {

View File

@@ -1,176 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import styles from './FieldsSelector.module.scss';
const DEFAULT_PANEL_WIDTH = 350;
const DEFAULT_PANEL_HEIGHT_OFFSET = 100;
const DEFAULT_PANEL_RIGHT_INSET = 100;
const DEFAULT_PANEL_TOP_INSET = 50;
interface FieldsSelectorProps {
isOpen: boolean;
title: string;
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
onClose: () => void;
signal: DataSource;
maxFields?: number;
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
}
function FieldsSelector({
isOpen,
title,
fields,
onFieldsChange,
onClose,
signal,
maxFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
}: FieldsSelectorProps): JSX.Element | null {
if (!isOpen) {
return null;
}
const resolvedHeight =
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
const resolvedPosition = defaultPosition ?? {
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
y: DEFAULT_PANEL_TOP_INSET,
};
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleAdd = useCallback(
(field: TelemetryFieldKey): void => {
if (maxFields !== undefined && draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.name === field.name)) {
return;
}
setDraftFields((prev) => [...prev, field]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
toast.success('Saved successfully', {
position: 'top-right',
});
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.name === fields[i]?.name)
),
[draftFields, fields],
);
const isAtLimit = maxFields !== undefined && draftFields.length >= maxFields;
return (
<FloatingPanel
isOpen
width={width}
height={resolvedHeight}
defaultPosition={resolvedPosition}
enableResizing={false}
>
<div className={styles.root}>
<div className={styles.header}>
<div className={styles.title}>
<TableColumnsSplit size={16} />
{title}
</div>
<X className={styles.closeIcon} size={16} onClick={onClose} />
</div>
<section>
<Input
className={styles.searchInput}
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
maxFields={maxFields}
/>
<OtherFields
signal={signal}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={handleDiscard}
prefix={<X width={14} height={14} />}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSave}
prefix={<Check width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
</FloatingPanel>
);
}
export default FieldsSelector;

View File

@@ -1 +0,0 @@
export { default } from './FieldsSelector';

View File

@@ -5,8 +5,6 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -52,14 +50,6 @@ function HeaderRightSection({
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenAIAssistant = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'header',
currentPage: normalizePage(location.pathname),
});
openAIAssistant();
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
@@ -111,7 +101,7 @@ function HeaderRightSection({
<Button
variant="solid"
color="secondary"
onClick={handleOpenAIAssistant}
onClick={openAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1

View File

@@ -50,7 +50,6 @@ import {
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -218,17 +217,20 @@ function LogDetailInner({
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
return log?.body || '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
try {
const json = JSON.parse(log?.body || '');
if (typeof json?.message === 'string' && json.message !== '') {
return json.message;
}
return log?.body || '';
} catch {
return log?.body || '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(

View File

@@ -9,10 +9,7 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -102,7 +99,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
parts.push(`${attributesText} ${data.body}`);
} else {
parts.push(attributesText);
}

View File

@@ -2,10 +2,7 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -90,7 +87,7 @@ export function useLogsTableColumns({
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
accessorFn: (log): string => log.body,
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (

View File

@@ -28,7 +28,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as unknown as string,
lastObservedAt: null as unknown as Date,
serviceAccountId: 'sa-1',
};

View File

@@ -29,14 +29,14 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as unknown as string,
lastObservedAt: null as unknown as Date,
serviceAccountId: 'sa-1',
},
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: '2026-03-10T10:00:00Z',
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
serviceAccountId: 'sa-1',
},
];

View File

@@ -626,10 +626,6 @@ function TanStackTableInner<TData>(
onChange={(value): void => {
setLimit(+value);
pagination.onLimitChange?.(+value);
if (page !== 1) {
setPage(1);
pagination.onPageChange?.(1);
}
}}
items={paginationPageSizeItems}
/>

View File

@@ -401,62 +401,6 @@ describe('TanStackTableView Integration', () => {
expect(onLimitChange).toHaveBeenCalledWith(20);
});
});
it('resets page to 1 when limit changes', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const onPageChange = jest.fn();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10, onPageChange },
enableQueryParams: true,
},
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Navigate to page 2
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
// Change page size
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLElement;
await user.click(comboboxTrigger);
const option20 = await screen.findByRole('option', { name: '20' });
await user.click(option20);
// Verify page reset to 1 (nuqs removes default values from URL)
await waitFor(() => {
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const lastPage = lastCall[0].searchParams.get('page');
expect(lastPage === '1' || lastPage === null).toBe(true);
expect(lastCall[0].searchParams.get('limit')).toBe('20');
});
// Verify onPageChange callback was called with 1
expect(onPageChange).toHaveBeenCalledWith(1);
});
});
describe('sorting', () => {

View File

@@ -108,7 +108,4 @@ export const REACT_QUERY_KEY = {
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
// Fields Selector Query Keys
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
} as const;

View File

@@ -29,6 +29,7 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALERT_TYPE_SELECTION: '/alerts/type-selection',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',

View File

@@ -1,20 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import HistorySidebar from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import {
normalizePage,
useAIAssistantAnalyticsContext,
} from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -31,7 +24,6 @@ import styles from './AIAssistantModal.module.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
@@ -44,7 +36,6 @@ export default function AIAssistantModal(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -64,10 +55,6 @@ export default function AIAssistantModal(): JSX.Element | null {
} else {
startNewConversation();
setShowHistory(false);
void logEvent(AIAssistantEvents.Opened, {
source: 'shortcut',
currentPage: normalizePage(pathname),
});
openModal();
}
return;
@@ -81,7 +68,7 @@ export default function AIAssistantModal(): JSX.Element | null {
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation, pathname]);
}, [isOpen, openModal, closeModal, startNewConversation]);
// ── Handlers ────────────────────────────────────────────────────────────────
@@ -90,28 +77,15 @@ export default function AIAssistantModal(): JSX.Element | null {
return;
}
closeModal();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the modal, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: the modal collapses to 'sidepane' in our
// taxonomy alongside the drawer.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation, analyticsCtx]);
}, [startNewConversation]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);

View File

@@ -5,12 +5,8 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import ConversationsList from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -36,35 +32,21 @@ export default function AIAssistantPanel(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the drawer, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: this handler only runs when the drawer
// itself is mounted, which is unambiguously the sidepane surface.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation, analyticsCtx]);
}, [startNewConversation]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {

View File

@@ -1,13 +1,9 @@
import { useCallback } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import { AIAssistantEvents } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -29,14 +25,6 @@ export default function AIAssistantTrigger(): JSX.Element | null {
exact: true,
});
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'icon',
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
@@ -47,7 +35,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
variant="solid"
color="primary"
className={styles.trigger}
onClick={handleOpen}
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<Bot size={20} />

View File

@@ -1,15 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import ChatInput, { autoContextKey } from '../components/ChatInput';
import ConversationSkeleton from '../components/ConversationSkeleton';
import VirtualizedMessages from '../components/VirtualizedMessages';
import { AIAssistantEvents } from '../events';
import { getAutoContexts } from '../getAutoContexts';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { MessageAttachment } from '../types';
import { MessageContext } from '../../../api/ai-assistant/chat';
@@ -43,7 +39,6 @@ export default function ConversationView({
);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const analyticsCtx = useAIAssistantAnalyticsContext(conversationId);
// Auto-derived contexts come from the route the user is currently looking
// at (dashboard detail, service metrics, an explorer, …). Skip when the
@@ -87,50 +82,14 @@ export default function ConversationView({
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => {
const hasAuto = contexts?.some((c) => c.source === 'auto') ?? false;
const hasManual = contexts?.some((c) => c.source === 'mention') ?? false;
let contextType: 'manual' | 'auto' | 'both' | undefined;
if (hasAuto && hasManual) {
contextType = 'both';
} else if (hasAuto) {
contextType = 'auto';
} else if (hasManual) {
contextType = 'manual';
}
void logEvent(AIAssistantEvents.MessageSent, {
...analyticsCtx,
queryLength: text.length,
hasContext: hasAuto || hasManual,
contextType,
respondingToClarification: Boolean(pendingClarificationHere),
});
void sendMessage(text, attachments, contexts);
},
[sendMessage, analyticsCtx, pendingClarificationHere],
[sendMessage],
);
// Wall-clock timestamp of the current streaming start, used to compute
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
const streamStartedAtRef = useRef<number | null>(null);
useEffect(() => {
if (!isStreamingHere) {
streamStartedAtRef.current = null;
return;
}
if (streamStartedAtRef.current === null) {
streamStartedAtRef.current = Date.now();
}
}, [isStreamingHere]);
const handleCancel = useCallback(() => {
const startedAt = streamStartedAtRef.current;
void logEvent(AIAssistantEvents.CancelClicked, {
threadId: analyticsCtx.threadId,
secondsSinceStart:
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
});
cancelStream(conversationId);
}, [cancelStream, conversationId, analyticsCtx.threadId]);
}, [cancelStream, conversationId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
@@ -175,7 +134,6 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -41,68 +41,12 @@ import {
Undo,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ActionsSection.module.scss';
interface ActionsSectionProps {
actions: MessageActionDTO[];
/** ID of the assistant message these actions belong to — used in analytics. */
messageId: string;
}
/**
* Resource-type strings the backend uses for `open_resource` and rollback
* actions. Centralized here so the route/module lookups below stay in sync.
*/
const ResourceType = {
dashboard: 'dashboard',
alert: 'alert',
service: 'service',
saved_view: 'saved_view',
logs_explorer: 'logs_explorer',
traces_explorer: 'traces_explorer',
metrics_explorer: 'metrics_explorer',
} as const;
/** Maps an open_resource action's resourceType to its product module name. */
function targetModuleForResource(resourceType: string): string | null {
switch (resourceType) {
case ResourceType.dashboard:
return 'dashboards';
case ResourceType.alert:
return 'alerts';
case ResourceType.service:
return 'apm';
case ResourceType.saved_view:
return 'savedViews';
case ResourceType.logs_explorer:
return 'logs';
case ResourceType.traces_explorer:
return 'traces';
case ResourceType.metrics_explorer:
return 'metrics';
default:
return null;
}
}
/** Maps an apply_filter signal to its product module name. */
function targetModuleForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return 'logs';
case ApplyFilterSignalDTO.traces:
return 'traces';
case ApplyFilterSignalDTO.metrics:
return 'metrics';
default:
return null;
}
}
type ChipState = 'idle' | 'loading' | 'success' | 'error';
@@ -150,23 +94,23 @@ function resourceRoute(
resourceId: string,
): string | null {
switch (resourceType) {
case ResourceType.dashboard:
case 'dashboard':
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case ResourceType.alert: {
case 'alert': {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case ResourceType.service:
case 'service':
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case ResourceType.saved_view:
case 'saved_view':
// No detail route — saved views land on the list page.
// Caller may provide signal-aware metadata in future; default to logs.
return ROUTES.LOGS_SAVE_VIEWS;
case ResourceType.logs_explorer:
case 'logs_explorer':
return ROUTES.LOGS_EXPLORER;
case ResourceType.traces_explorer:
case 'traces_explorer':
return ROUTES.TRACES_EXPLORER;
case ResourceType.metrics_explorer:
case 'metrics_explorer':
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
@@ -280,24 +224,6 @@ function actionKey(action: MessageActionDTO, index: number): string {
: `${action.kind}:${action.label}:${index}`;
}
/**
* Resolves the prompt to send for a follow_up action. The chip's `label` is
* the short display text (e.g. "Python setup"); the real prompt lives in
* `input.intent` per the schema doc. Falls back to label defensively so a
* malformed server payload doesn't drop the click silently. Both branches
* are trimmed so whitespace-only payloads don't become whitespace messages.
*/
function followUpIntent(action: MessageActionDTO): string {
const intent = action.input?.intent;
if (typeof intent === 'string') {
const trimmed = intent.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return action.label.trim();
}
/** Maps a signal to its target explorer route. */
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
@@ -427,12 +353,10 @@ function rollbackCall(
*/
export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
// Per-chip click state, keyed by chip key (see `key` below). Persists
@@ -506,39 +430,13 @@ export default function ActionsSection({
switch (action.kind) {
case MessageActionKindDTO.open_docs: {
if (action.url) {
void logEvent(AIAssistantEvents.DocOpened, {
threadId,
messageId,
docPath: action.url,
});
openInNewTab(action.url);
}
break;
}
case MessageActionKindDTO.follow_up: {
const intent = followUpIntent(action);
if (intent) {
// Fire SuggestedPromptClicked + MessageSent so analytics can compute
// both the click-through rate against follow-ups offered *and* keep
// the unified send funnel intact. `category` distinguishes server-
// emitted follow-ups from the empty-state grid. `promptId` stays the
// label so dashboards group identical chip texts together regardless
// of the dynamic intent payload.
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
threadId,
messageId,
promptId: action.label,
category: SuggestedPromptCategory.FollowUp,
});
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: intent.length,
hasContext: false,
respondingToClarification: false,
});
void sendMessage(intent);
if (action.label) {
void sendMessage(action.label);
}
break;
}
@@ -546,12 +444,6 @@ export default function ActionsSection({
if (action.resourceType && action.resourceId) {
const path = resourceRoute(action.resourceType, action.resourceId);
if (path) {
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(action.resourceType),
resourceId: action.resourceId,
});
history.push(path);
}
}
@@ -564,13 +456,6 @@ export default function ActionsSection({
break;
}
case MessageActionKindDTO.apply_filter: {
if (action.signal) {
void logEvent(AIAssistantEvents.ApplyFilterClicked, {
threadId,
messageId,
targetModule: targetModuleForSignal(action.signal),
});
}
applyFilter(action, {
history,
pathname,

View File

@@ -5,17 +5,13 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import getSessionStorage from 'api/browser/sessionstorage/get';
import setSessionStorage from 'api/browser/sessionstorage/set';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
@@ -26,8 +22,6 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { AIAssistantEvents, getBrowserInfo } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
@@ -143,8 +137,6 @@ function autoContextCategory(ctx: MessageContext): string {
const MAX_INPUT_LENGTH = 20000;
const WARNING_THRESHOLD = 15000;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
@@ -376,28 +368,6 @@ export default function ChatInput({
// ── Voice input ────────────────────────────────────────────────────────────
const analyticsCtx = useAIAssistantAnalyticsContext();
// Captured at the start of a voice session, consumed when it ends.
// Tracks both the trigger (button vs. PTT shortcut) and the wall-clock
// start time so we can attribute `durationMs` on the Voice input used
// event regardless of which control ended the session.
const voiceStartedAtRef = useRef<number | null>(null);
const voiceSourceRef = useRef<'button' | 'shortcut' | null>(null);
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
// so we hide the mic button for the rest of the tab session — silent
// retries don't help, and Chromium derivatives without the Google Speech
// API key always fail with `network` no matter how many times the user
// clicks. Persisted to sessionStorage so a page reload doesn't surface the
// button again (closing the tab still resets, in case the user fixed
// permissions or switched browsers).
const [voiceUnavailable, setVoiceUnavailable] = useState(
() => getSessionStorage(VOICE_UNAVAILABLE_KEY) === 'true',
);
const markVoiceUnavailable = useCallback((): void => {
setVoiceUnavailable(true);
setSessionStorage(VOICE_UNAVAILABLE_KEY, 'true');
}, []);
const {
isListening,
isSupported,
@@ -418,81 +388,9 @@ export default function ChatInput({
setText(capText(committedTextRef.current + separator + transcriptText));
}
},
onError: (error) => {
// Guard against double-fire: Chrome can fire `onerror` more than
// once per session when `continuous = true` (it retries internally
// before giving up). Only fire the analytics event for the first
// error in a given session — voiceSourceRef being null means we've
// already handled it.
const source = voiceSourceRef.current;
if (source === null) {
return;
}
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
void logEvent(AIAssistantEvents.VoiceInputFailed, {
...analyticsCtx,
...getBrowserInfo(),
source,
errorType: error,
});
if (error === 'network') {
markVoiceUnavailable();
toast.error('Voice input unavailable in this browser', {
description:
'This browser cannot reach the speech recognition service. Try Google Chrome or Microsoft Edge.',
});
} else if (error === 'not-allowed') {
markVoiceUnavailable();
toast.error('Microphone access denied', {
description:
'Grant microphone permission in your browser settings to use voice input.',
});
} else if (error === 'not-supported') {
markVoiceUnavailable();
toast.error('Voice input is not supported in this browser.');
}
// `no-speech` is benign (just silence) — don't toast or hide.
},
});
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
const startVoiceInput = useCallback(
(source: 'button' | 'shortcut') => {
// Defense in depth: the button is hidden when `voiceUnavailable` is
// true, but the PTT shortcut listener can still call us. Bailing here
// keeps a single source of truth and prevents repeat `Voice input
// failed` events in the same session.
if (voiceUnavailable) {
return;
}
voiceStartedAtRef.current = Date.now();
voiceSourceRef.current = source;
start();
},
[start, voiceUnavailable],
);
const fireVoiceInputEvent = useCallback(
(outcome: 'sent' | 'discarded') => {
const startedAt = voiceStartedAtRef.current;
const source = voiceSourceRef.current;
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
if (startedAt === null || source === null) {
return;
}
void logEvent(AIAssistantEvents.VoiceInputUsed, {
...analyticsCtx,
...getBrowserInfo(),
source,
outcome,
durationMs: Date.now() - startedAt,
});
},
[analyticsCtx],
);
const showMic = isSupported && micPermission !== 'denied';
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
@@ -500,17 +398,15 @@ export default function ChatInput({
committedTextRef.current = capText(text);
// Stop recognition without triggering onTranscript again (would double-append).
discard();
fireVoiceInputEvent('sent');
await handleSend();
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
}, [text, discard, handleSend, capText]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
fireVoiceInputEvent('discarded');
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard, fireVoiceInputEvent]);
}, [discard]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
@@ -519,7 +415,7 @@ export default function ChatInput({
// "session active" ref so a held key only calls `start()` once.
const pttActiveRef = useRef(false);
useEffect(() => {
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
if (!isSupported || micPermission === 'denied') {
return undefined;
}
@@ -536,7 +432,7 @@ export default function ChatInput({
return; // ignore auto-repeat
}
pttActiveRef.current = true;
startVoiceInput('shortcut');
start();
};
const handleKeyUp = (e: KeyboardEvent): void => {
@@ -570,10 +466,9 @@ export default function ChatInput({
}, [
isSupported,
micPermission,
voiceUnavailable,
disabled,
isStreaming,
startVoiceInput,
start,
handleStopAndSend,
]);
@@ -1008,7 +903,7 @@ export default function ChatInput({
<Button
variant="ghost"
size="icon"
onClick={(): void => startVoiceInput('button')}
onClick={start}
disabled={disabled}
aria-label="Start voice input"
className={styles.micBtn}

View File

@@ -9,7 +9,6 @@ import {
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import logEvent from 'api/common/logEvent';
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
ClarificationEventDTO,
@@ -17,8 +16,6 @@ import type {
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { CircleHelp, Send, X } from '@signozhq/icons';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ClarificationForm.module.scss';
@@ -47,8 +44,6 @@ export default function ClarificationForm({
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const { threadId, page, mode } =
useAIAssistantAnalyticsContext(conversationId);
const fields = clarification.fields ?? [];
const initialAnswers = Object.fromEntries(
@@ -65,18 +60,6 @@ export default function ClarificationForm({
const handleSubmit = async (): Promise<void> => {
setSubmitted(true);
// Approximate queryLength as the JSON encoding of the form answers — the
// clarification API doesn't render a single user-visible string, but the
// JSON size is a reasonable stand-in for "how much did the user provide".
const queryLength = JSON.stringify(answers).length;
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength,
hasContext: false,
respondingToClarification: true,
});
await submitClarification(
conversationId,
clarification.clarificationId,
@@ -86,10 +69,6 @@ export default function ClarificationForm({
const handleCancel = (): void => {
setCancelled(true);
void logEvent(AIAssistantEvents.CancelClicked, {
threadId,
secondsSinceStart: null,
});
cancelStream(conversationId);
};

View File

@@ -5,9 +5,6 @@ import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Conversation } from '../../types';
import { useVariant } from '../../VariantContext';
@@ -139,17 +136,6 @@ export default function ConversationsList({
const handleSelect = (id: string): void => {
const conv = conversations[id];
// Skip re-selecting the currently active thread — Notion-style click on
// the highlighted row in the history list shouldn't inflate the funnel.
const isReselectingActive = id === activeConversationId;
if (conv?.threadId && !isReselectingActive) {
void logEvent(AIAssistantEvents.ThreadOpenedFromHistory, {
threadId: conv.threadId,
threadAgeDays: Math.floor(
(Date.now() - conv.createdAt) / (24 * 60 * 60 * 1000),
),
});
}
if (conv?.threadId) {
// Always load from backend — refreshes messages and reconnects
// to active execution if the thread is still busy.

View File

@@ -144,7 +144,7 @@ export default function MessageBubble({
)}
{!isUser && message.actions && message.actions.length > 0 && (
<ActionsSection actions={message.actions} messageId={message.id} />
<ActionsSection actions={message.actions} />
)}
</div>
</div>

View File

@@ -8,10 +8,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { FeedbackRating, Message } from '../../types';
@@ -58,7 +54,6 @@ export default function MessageFeedback({
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { threadId } = useAIAssistantAnalyticsContext();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -96,21 +91,10 @@ export default function MessageFeedback({
}, [message.createdAt]);
const handleCopy = useCallback((): void => {
void logEvent(AIAssistantEvents.MessageCopied, {
role: message.role,
messageId: message.id,
hadToolCalls: Boolean(message.blocks?.some((b) => b.type === 'tool_call')),
});
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [
copyToClipboard,
message.content,
message.id,
message.role,
message.blocks,
]);
}, [copyToClipboard, message.content]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
@@ -123,31 +107,20 @@ export default function MessageFeedback({
return;
}
setVote(rating);
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: 'up',
hasComment: false,
commentLength: 0,
});
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback, threadId],
[vote, message.id, submitMessageFeedback],
);
const handleSubmitNegative = useCallback((): void => {
setVote('negative');
setIsNegativeDialogOpen(false);
const trimmed = negativeComment.trim();
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: 'down',
hasComment: trimmed.length > 0,
commentLength: trimmed.length,
});
submitMessageFeedback(message.id, 'negative', trimmed || undefined);
}, [message.id, negativeComment, submitMessageFeedback, threadId]);
submitMessageFeedback(
message.id,
'negative',
negativeComment.trim() || undefined,
);
}, [message.id, negativeComment, submitMessageFeedback]);
return (
<>

View File

@@ -4,9 +4,6 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { Message } from '../../types';
import styles from './UserMessageActions.module.scss';
@@ -28,15 +25,10 @@ export default function UserMessageActions({
const [, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback((): void => {
void logEvent(AIAssistantEvents.MessageCopied, {
role: message.role,
messageId: message.id,
hadToolCalls: false,
});
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content, message.id, message.role]);
}, [copyToClipboard, message.content]);
return (
<div className={styles.actions}>

View File

@@ -10,10 +10,6 @@ import {
Sparkles,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../../types';
import MessageBubble from '../MessageBubble';
@@ -50,24 +46,17 @@ interface VirtualizedMessagesProps {
conversationId: string;
messages: Message[];
isStreaming: boolean;
/**
* Called when a user clicks an empty-state suggested prompt. Routed
* through the parent so analytics (Message sent) fire with the same
* page/mode/context attribution as a normal send.
*/
onSendSuggestedPrompt: (text: string) => void;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
onSendSuggestedPrompt,
}: VirtualizedMessagesProps): JSX.Element {
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
);
@@ -96,13 +85,9 @@ export default function VirtualizedMessages({
if (isStreaming) {
return;
}
void logEvent(AIAssistantEvents.RegenerateClicked, {
messageId,
threadId,
});
void regenerateAssistantMessage(conversationId, messageId);
},
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
[conversationId, isStreaming, regenerateAssistantMessage],
);
// Scroll all the way to the actual bottom — including the 64px of bottom
@@ -161,11 +146,7 @@ export default function VirtualizedMessages({
color="secondary"
className={styles.emptyChip}
onClick={(): void => {
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
promptId: s.text,
category: SuggestedPromptCategory.EmptyState,
});
onSendSuggestedPrompt(s.text);
sendMessage(s.text);
}}
prefix={<s.icon size={14} />}
>

View File

@@ -1,10 +1,7 @@
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import { Check, X } from '@signozhq/icons';
import { AIAssistantEvents } from '../../../events';
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
@@ -40,7 +37,6 @@ export default function ConfirmBlock({
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
// Durable answered state — survives re-renders/remounts
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
@@ -51,14 +47,6 @@ export default function ConfirmBlock({
if (messageId) {
markBlockAnswered(messageId, choice);
}
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: responseText.length,
hasContext: false,
respondingToClarification: false,
});
sendMessage(responseText);
};

View File

@@ -1,11 +1,8 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import { Checkbox, Radio } from 'antd';
import { AIAssistantEvents } from '../../../events';
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
@@ -39,7 +36,6 @@ export default function InteractiveQuestion({
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
// Persist selected state locally only for the pending (not-yet-submitted) case
const [selected, setSelected] = useState<string[]>([]);
@@ -56,14 +52,6 @@ export default function InteractiveQuestion({
if (messageId) {
markBlockAnswered(messageId, answer);
}
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: answer.length,
hasContext: false,
respondingToClarification: false,
});
sendMessage(answer);
};

View File

@@ -1,81 +0,0 @@
/**
* Analytics event names for the AI Assistant feature. Backend-emitted events
* (Execution finished, Approval resolved, Resource mutated, Clarification
* requested, Limit hit) are not declared here — they fire from the AI service.
*/
export interface BrowserInfo {
browserName: string;
browserVersion: string;
}
type NavigatorWithBrandHints = Navigator & {
userAgentData?: { brands: { brand: string; version: string }[] };
brave?: { isBrave: () => Promise<boolean> };
};
/**
* We mainly need to distinguish Chrome / Edge (Speech API works) from Chromium
* derivatives (no Google API key → voice fails with `network`). UA sniffing is
* the source of truth for derivative identification; `userAgentData` is used
* only as a fast happy path for Chrome / Edge. Brave needs its own probe — it
* advertises Chrome in both UA and brand hints.
*/
export function getBrowserInfo(): BrowserInfo {
if (typeof navigator === 'undefined') {
return { browserName: 'unknown', browserVersion: 'unknown' };
}
const nav = navigator as NavigatorWithBrandHints;
const ua = nav.userAgent;
// Order matters: derivatives put "Chrome" in their UA; Chrome puts "Safari".
const matchers: { name: string; re: RegExp }[] = [
{ name: 'Edge', re: /Edg(?:e|A|iOS)?\/([\d.]+)/ },
{ name: 'Opera', re: /OPR\/([\d.]+)/ },
{ name: 'Vivaldi', re: /Vivaldi\/([\d.]+)/ },
{ name: 'Chrome', re: /Chrome\/([\d.]+)/ },
{ name: 'Firefox', re: /Firefox\/([\d.]+)/ },
{ name: 'Safari', re: /Version\/([\d.]+).*Safari/ },
];
let browserName = 'unknown';
let browserVersion = 'unknown';
for (const { name, re } of matchers) {
const m = ua.match(re);
if (m) {
browserName = name;
browserVersion = m[1];
break;
}
}
// Brave hides as Chrome in UA + brand hints; its probe is the only tell.
if (nav.brave?.isBrave) {
browserName = 'Brave';
}
return { browserName, browserVersion };
}
export const SuggestedPromptCategory = {
FollowUp: 'follow_up',
EmptyState: 'empty_state',
} as const;
export type SuggestedPromptCategory =
(typeof SuggestedPromptCategory)[keyof typeof SuggestedPromptCategory];
export enum AIAssistantEvents {
Opened = 'AI Assistant: Opened',
MessageSent = 'AI Assistant: Message sent',
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
CancelClicked = 'AI Assistant: Cancel clicked',
RegenerateClicked = 'AI Assistant: Regenerate clicked',
MessageCopied = 'AI Assistant: Message copied',
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
ResourceOpened = 'AI Assistant: Resource opened',
DocOpened = 'AI Assistant: Doc opened',
ApplyFilterClicked = 'AI Assistant: Apply filter clicked',
ThreadOpenedFromHistory = 'AI Assistant: Thread opened from history',
VoiceInputUsed = 'AI Assistant: Voice input used',
VoiceInputFailed = 'AI Assistant: Voice input failed',
NewChatClicked = 'AI Assistant: New chat clicked',
}

View File

@@ -1,60 +0,0 @@
import { matchPath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { useVariant } from '../VariantContext';
export interface AIAssistantAnalyticsContext {
/** Backend thread ID for the resolved conversation; undefined before the first send. */
threadId: string | undefined;
/**
* Normalised route template for the current page (e.g. `/dashboard/:dashboardId`).
* Falls back to the raw pathname for routes not in ROUTES. We normalise to keep
* analytics cardinality bounded and avoid leaking customer identifiers
* (dashboard IDs, service names, trace IDs, conversation IDs) into the event.
*/
page: string;
/** Surface the assistant is rendered on. `panel` / `modal` collapse to `sidepane`. */
mode: 'sidepane' | 'full_screen';
}
// Pre-sorted longest-first so more specific templates match before their
// less specific siblings (e.g. `/services/:s/top-level-operations` wins
// over `/services/:s`). Module-level — ROUTES is static.
const ROUTE_TEMPLATES = Object.values(ROUTES).sort(
(a, b) => b.length - a.length,
);
export function normalizePage(pathname: string): string {
for (const template of ROUTE_TEMPLATES) {
if (matchPath(pathname, { path: template, exact: true })) {
return template;
}
}
return pathname;
}
/**
* Shared base attributes for AI Assistant analytics events (Message sent,
* Cancel clicked, Feedback submitted, Resource/Doc/Apply filter, …).
*
* Pass `conversationId` when the caller is scoped to a specific
* conversation (e.g. `ClarificationForm`, `VirtualizedMessages`); omit
* to fall back to the store's active conversation.
*/
export function useAIAssistantAnalyticsContext(
conversationId?: string,
): AIAssistantAnalyticsContext {
const { pathname } = useLocation();
const variant = useVariant();
const threadId = useAIAssistantStore((s) => {
const id = conversationId ?? s.activeConversationId;
return id ? s.conversations[id]?.threadId : undefined;
});
return {
threadId,
page: normalizePage(pathname),
mode: variant === 'page' ? 'full_screen' : 'sidepane',
};
}

View File

@@ -1,23 +1,12 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
@@ -65,13 +54,20 @@ describe('Alert rule documentation redirection', () => {
window.open = mockWindowOpen;
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERT_TYPE_SELECTION}`,
}),
}));
beforeEach(() => {
act(() => {
renderResult = render(
<CreateAlertPage />,
<AlertTypeSelectionPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
initialRoute: ROUTES.ALERT_TYPE_SELECTION,
},
);
});

View File

@@ -15,18 +15,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({

View File

@@ -1,75 +0,0 @@
.create-alert-tabs {
&__extra {
display: flex;
align-items: center;
gap: 16px;
}
}
.create-alert-wrapper {
margin-top: 10px;
.divider {
border-color: var(--l1-border);
margin: 16px 0;
}
.breadcrumb-divider {
margin-top: 10px;
}
}
.create-alert__breadcrumb {
padding-left: 16px;
ol {
align-items: center;
}
.breadcrumb-item {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
padding: 0;
}
.ant-breadcrumb-separator,
.breadcrumb-item--last {
color: var(--muted-foreground);
font-family: 'Geist Mono';
}
}
.alerts-container {
.top-level-tab.periscope-tab {
padding: 2px 0;
}
.ant-tabs {
&-nav {
padding: 0 8px;
margin-bottom: 0 !important;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
&-tab {
&[data-node-key='TriggeredAlerts'] {
margin-left: 16px;
}
&:not(:first-of-type) {
margin-left: 24px !important;
}
[aria-selected='false'] {
.periscope-tab {
color: var(--l2-foreground);
}
}
}
}
}

View File

@@ -1,111 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import SelectAlertType from '..';
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
describe('SelectAlertType', () => {
const mockOnSelect = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render all alert type options when anomaly detection is enabled', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
});
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.queryByText('anomaly_based_alert')).not.toBeInTheDocument();
});
it('should call onSelect with metrics based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.METRICS_BASED_ALERT,
false,
);
});
it('should call onSelect with anomaly based alert type', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('anomaly_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.ANOMALY_BASED_ALERT,
false,
);
});
it('should call onSelect with log based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('log_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(AlertTypes.LOGS_BASED_ALERT, false);
});
it('should call onSelect with traces based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('traces_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.TRACES_BASED_ALERT,
false,
);
});
it('should call onSelect with exceptions based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('exceptions_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.EXCEPTIONS_BASED_ALERT,
false,
);
});
});

View File

@@ -1,37 +1,13 @@
import { fireEvent, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as useCompositeQueryParamHooks from 'hooks/queryBuilder/useGetCompositeQueryParam';
import * as navigateHooks from 'hooks/useSafeNavigate';
import * as useUrlQueryHooks from 'hooks/useUrlQuery';
import * as appHooks from 'providers/App/App';
import { render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { DataSource } from 'types/common/queryBuilder';
import CreateAlertRule from '../index';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: function MockDateTimeSelector(): JSX.Element {
return <div data-testid="datetime-selector">Mock DateTime Selector</div>;
},
}));
jest.mock('container/FormAlertRules', () => ({
__esModule: true,
default: function MockFormAlertRules({
@@ -72,14 +48,10 @@ const useCompositeQueryParamSpy = jest.spyOn(
'useGetCompositeQueryParam',
);
const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockSetUrlQuery = jest.fn();
const mockToString = jest.fn();
const mockGetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockDeleteUrlQuery = jest.fn();
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
@@ -91,13 +63,8 @@ describe('CreateAlertRule', () => {
set: mockSetUrlQuery,
toString: mockToString,
get: mockGetUrlQuery,
delete: mockDeleteUrlQuery,
} as Partial<URLSearchParams> as URLSearchParams);
useCompositeQueryParamSpy.mockReturnValue(initialQueriesMap.metrics);
useSafeNavigateSpy.mockReturnValue({
safeNavigate: mockSafeNavigate,
});
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
@@ -105,53 +72,18 @@ describe('CreateAlertRule', () => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render new flow when alertType is provided', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
it('should render new flow by default', () => {
mockGetUrlQuery.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
});
it('should render type selection when no alertType in URL and no compositeQuery', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should skip type selection and render alert form when compositeQuery is present', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{
...initialQueriesMap.metrics.builder.queryData[0],
dataSource: DataSource.METRICS,
},
],
},
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
@@ -179,13 +111,8 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
});
it('should use alertType from URL over compositeQuery dataSource', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
return AlertTypes.LOGS_BASED_ALERT;
}
return null;
});
it('should use alertType from compositeQuery dataSource when alertType is not in URL', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue({
...initialQueriesMap.metrics,
builder: {
@@ -200,123 +127,14 @@ describe('CreateAlertRule', () => {
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.TRACES_BASED_ALERT)).toBeInTheDocument();
});
describe('handleSelectType navigation', () => {
beforeEach(() => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
});
it('should navigate with threshold alert params for metrics alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for logs alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('log_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.LOGS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for traces alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('traces_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.TRACES_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for exceptions alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('exceptions_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.EXCEPTIONS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with anomaly detection params for anomaly alert', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('anomaly_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'anomaly_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should default to METRICS_BASED_ALERT when no alertType and no compositeQuery', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
});

View File

@@ -208,11 +208,3 @@ export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
};
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',
[AlertTypes.LOGS_BASED_ALERT]: 'Log-Based Alert',
[AlertTypes.TRACES_BASED_ALERT]: 'Traces-Based Alert',
[AlertTypes.EXCEPTIONS_BASED_ALERT]: 'Exceptions-Based Alert',
};

View File

@@ -1,34 +1,21 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Form, Tabs, TabsProps } from 'antd';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useMemo } from 'react';
import { Form } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import SelectAlertType from './SelectAlertType';
import './CreateAlertRule.styles.scss';
import { ALERTS_VALUES_MAP } from './defaults';
function CreateRules(): JSX.Element {
const [formInstance] = Form.useForm();
const compositeQuery = useGetCompositeQueryParam();
const queryParams = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
@@ -36,15 +23,6 @@ function CreateRules(): JSX.Element {
const showClassicCreateAlertsPageFlag =
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
const isTypeSelectionMode =
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
useEffect(() => {
if (isTypeSelectionMode) {
logEvent('Alert: New alert data source selection page visited', {});
}
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
@@ -67,142 +45,22 @@ function CreateRules(): JSX.Element {
[alertType, version],
);
const handleTabChange = useCallback(
(tab: string): void => {
queryParams.set('tab', tab);
queryParams.delete('subTab');
queryParams.delete('search');
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${queryParams.toString()}`);
},
[safeNavigate, queryParams],
);
// Load old alerts flow always for anomaly based alerts and when showClassicCreateAlertsPage is true
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.alertType, type);
}
safeNavigate(`${ROUTES.ALERTS_NEW}?${queryParams.toString()}`, { newTab });
},
[queryParams, safeNavigate],
);
const alertContent = useMemo(() => {
if (isTypeSelectionMode) {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
return <CreateAlertV2 alertType={alertType} />;
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
const items: TabsProps['items'] = [
{
label: (
<div className="periscope-tab top-level-tab">
<GalleryVerticalEnd size={14} />
Triggered Alerts
</div>
),
key: AlertListTabs.TRIGGERED_ALERTS,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<Pyramid size={14} />
Alert Rules
</div>
),
key: AlertListTabs.ALERT_RULES,
children: (
<div className="create-alert-wrapper">
<AlertBreadcrumb
className="create-alert__breadcrumb"
items={
isTypeSelectionMode
? [
{
title: 'Alert Rules',
route: `${ROUTES.LIST_ALL_ALERT}?tab=${AlertListTabs.ALERT_RULES}`,
},
{ title: 'Select Alert Type', isLast: true },
]
: [
{
title: 'Alert Rules',
route: `${ROUTES.LIST_ALL_ALERT}?tab=${AlertListTabs.ALERT_RULES}`,
},
{ title: 'Select Alert Type', route: ROUTES.ALERTS_NEW },
{
title: ALERT_TYPE_BREADCRUMB_TITLE[alertType],
isLast: true,
},
]
}
/>
{alertContent}
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Configuration
</div>
),
key: AlertListTabs.CONFIGURATION,
children: null,
},
];
return (
<Tabs
destroyInactiveTabPane
items={items}
activeKey={AlertListTabs.ALERT_RULES}
onChange={handleTabChange}
className="alerts-container create-alert-tabs"
tabBarExtraContent={
<div className="create-alert-tabs__extra">
<DateTimeSelector showAutoRefresh />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
/>
);
return <CreateAlertV2 alertType={alertType} />;
}
export default CreateRules;

View File

@@ -9,7 +9,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
import { useCreateAlertState } from '../context';
@@ -19,7 +18,6 @@ import './styles.scss';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
@@ -76,13 +74,9 @@ function CreateAlertHeader(): JSX.Element {
<Input
type="text"
value={alertState.name}
onChange={(e): void => {
const newName = e.target.value;
setAlertState({ type: 'SET_ALERT_NAME', payload: newName });
if (isEditMode && alertRuleContext?.setAlertRuleName) {
alertRuleContext.setAlertRuleName(newName);
}
}}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
}
className="alert-header__input title"
placeholder="Enter alert rule name"
data-testid="alert-name-input"

View File

@@ -20,11 +20,6 @@ import {
} from './utils';
import './styles.scss';
import {
invalidateGetRuleByID,
invalidateListRules,
} from 'api/generated/services/rules';
import { useQueryClient } from 'react-query';
function Footer(): JSX.Element {
const {
@@ -120,7 +115,6 @@ function Footer(): JSX.Element {
testAlertRule,
]);
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
@@ -139,9 +133,6 @@ function Footer(): JSX.Element {
},
{
onSuccess: () => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void invalidateListRules(queryClient);
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},

View File

@@ -7,7 +7,6 @@ import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationS
import * as createAlertState from '../../context';
import Footer from '../Footer';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
// Mock the hooks used by Footer component
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -65,12 +64,6 @@ const mockAlertContextState = createMockAlertContextState({
},
});
const WrappedFooter = (): JSX.Element => (
<MockQueryClientProvider>
<Footer />
</MockQueryClientProvider>
);
jest
.spyOn(createAlertState, 'useCreateAlertState')
.mockReturnValue(mockAlertContextState);
@@ -104,20 +97,20 @@ describe('Footer', () => {
});
it('should render the component with 3 buttons', () => {
render(<WrappedFooter />);
render(<Footer />);
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('discard action works correctly', () => {
render(<WrappedFooter />);
render(<Footer />);
fireEvent.click(screen.getByText(DISCARD_TEXT));
expect(mockDiscardAlertRule).toHaveBeenCalled();
});
it('save alert rule action works correctly', () => {
render(<WrappedFooter />);
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockCreateAlertRule).toHaveBeenCalled();
});
@@ -127,13 +120,13 @@ describe('Footer', () => {
...mockAlertContextState,
isEditMode: true,
});
render(<WrappedFooter />);
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockUpdateAlertRule).toHaveBeenCalled();
});
it('test notification action works correctly', () => {
render(<WrappedFooter />);
render(<Footer />);
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
expect(mockTestAlertRule).toHaveBeenCalled();
});
@@ -143,7 +136,7 @@ describe('Footer', () => {
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -159,7 +152,7 @@ describe('Footer', () => {
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
@@ -176,7 +169,7 @@ describe('Footer', () => {
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
@@ -196,7 +189,7 @@ describe('Footer', () => {
name: '',
},
});
render(<WrappedFooter />);
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -224,7 +217,7 @@ describe('Footer', () => {
},
});
render(<WrappedFooter />);
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -252,7 +245,7 @@ describe('Footer', () => {
},
});
render(<WrappedFooter />);
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -268,7 +261,7 @@ describe('Footer', () => {
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
// When testing alert rule, the play icon is replaced with a loader icon
expect(
@@ -283,7 +276,7 @@ describe('Footer', () => {
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
// When updating alert rule, the check icon is replaced with a loader icon
expect(
@@ -298,7 +291,7 @@ describe('Footer', () => {
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<WrappedFooter />);
render(<Footer />);
// When creating alert rule, the check icon is replaced with a loader icon
expect(

View File

@@ -186,40 +186,77 @@
display: flex;
flex-direction: column;
section {
.section-1 {
display: flex;
flex-direction: column;
align-items: start;
border-bottom: 1px solid var(--l1-border);
.ant-btn {
display: flex;
width: 100%;
height: unset;
padding: 8px;
height: 20px;
padding: 16px 18px 18px 14px;
align-items: center;
gap: 12px;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.section-2 {
display: flex;
flex-direction: column;
align-items: start;
border-bottom: 1px solid var(--l1-border);
.ant-btn {
display: flex;
width: 100%;
height: 20px;
padding: 16px 18px 18px 14px;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.delete-dashboard {
display: flex;
flex-direction: column;
align-items: start;
.section-1,
.section-2 {
border-bottom: 1px solid var(--l1-border);
}
.delete-dashboard .ant-btn {
color: var(--bg-cherry-400) !important;
.ant-typography {
display: flex;
width: 100%;
height: 20px;
padding: 16px 18px 18px 14px;
align-items: center;
gap: 6px;
color: var(--bg-cherry-400) !important;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
}
}

View File

@@ -211,12 +211,7 @@
display: grid;
grid-template-columns: max-content 1fr;
.typography-variables {
display: block;
}
.default-value-description {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;

View File

@@ -11,7 +11,7 @@ import {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
@@ -201,7 +201,7 @@ function GeneralDashboardSettings(): JSX.Element {
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>

View File

@@ -38,7 +38,6 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
import Tabs2 from 'periscope/components/Tabs2';
import { useAlertRuleOptional } from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
@@ -93,6 +92,7 @@ const ALERT_SETUP_GUIDE_URLS: Record<AlertTypes, string> = {
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function FormAlertRules({
alertType,
formInstance,
@@ -160,32 +160,6 @@ function FormAlertRules({
const [alertDef, setAlertDef] = useState<AlertDef>(initialValue);
const [yAxisUnit, setYAxisUnit] = useState<string>(currentQuery.unit || '');
const alertRuleContext = useAlertRuleOptional();
const providerAlertName = alertRuleContext?.alertRuleName;
useEffect(() => {
if (providerAlertName) {
setAlertDef((prev) => {
if (prev.alert === providerAlertName) {
return prev;
}
return { ...prev, alert: providerAlertName };
});
formInstance.setFieldsValue({ alert: providerAlertName });
}
}, [providerAlertName, formInstance]);
// Wrap setAlertDef to sync alert name to provider when user types
const handleSetAlertDef = useCallback(
(newDef: AlertDef) => {
setAlertDef(newDef);
// Sync alert name change to provider for header display
if (newDef.alert !== alertDef.alert && alertRuleContext?.setAlertRuleName) {
alertRuleContext.setAlertRuleName(newDef.alert);
}
},
[alertDef.alert, alertRuleContext],
);
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string | null>(null);
@@ -706,7 +680,7 @@ function FormAlertRules({
const renderBasicInfo = (): JSX.Element => (
<BasicInfo
alertDef={alertDef}
setAlertDef={handleSetAlertDef}
setAlertDef={setAlertDef}
isNewRule={isNewRule}
/>
);

View File

@@ -26,13 +26,10 @@
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.widget-header-title {
max-width: 80%;
min-width: 0;
}
.widget-header-actions {

View File

@@ -438,7 +438,9 @@ function MultiIngestionSettings(): JSX.Element {
data: {
name: values.name,
tags: updatedTags,
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
expires_at: new Date(
dayjs(values.expires_at).endOf('day').toISOString(),
),
},
},
{
@@ -469,11 +471,13 @@ function MultiIngestionSettings(): JSX.Element {
const requestPayload = {
name: values.name,
tags: updatedTags,
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
};
createIngestionKey(
{ data: requestPayload },
{
data: requestPayload,
},
{
onSuccess: (_data) => {
notifications.success({

View File

@@ -79,12 +79,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key One',
expires_at: TEST_EXPIRES_AT,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
@@ -160,12 +160,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Logs',
expires_at: TEST_EXPIRES_AT,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
@@ -238,12 +238,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: KEY_NAME,
expires_at: TEST_EXPIRES_AT,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
@@ -299,12 +299,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Regular',
expires_at: TEST_EXPIRES_AT,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret1',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [],
},
@@ -319,12 +319,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Search Result',
expires_at: TEST_EXPIRES_AT,
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret2',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [],
},

View File

@@ -111,7 +111,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
number: allAlertRules?.length,
layout: 'new',
});
safeNavigate(ROUTES.ALERTS_NEW, {
safeNavigate(ROUTES.ALERT_TYPE_SELECTION, {
newTab: isModifierKeyPressed(e),
});
},

View File

@@ -13,9 +13,9 @@ describe('filterAlerts', () => {
const mockAlertBase: Partial<RuletypesRuleDTO> = {
state: 'active' as RuletypesAlertStateDTO,
disabled: false,
createdAt: '2024-01-01T00:00:00Z',
createdAt: new Date('2024-01-01T00:00:00Z'),
createdBy: 'test-user',
updatedAt: '2024-01-01T00:00:00Z',
updatedAt: new Date('2024-01-01T00:00:00Z'),
updatedBy: 'test-user',
version: '1',
condition: {

View File

@@ -22,9 +22,3 @@
gap: 8px;
}
}
// FieldRenderer is used inside log/trace/metric detail drawers (z-index 1000).
// The design-system tooltip defaults to z-index 50 and would render behind them.
.field-renderer-tooltip-content {
--tooltip-z-index: 1000;
}

View File

@@ -1,5 +1,4 @@
import { Divider } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Divider, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
@@ -8,10 +7,6 @@ import { getFieldAttributes } from './utils';
import './FieldRenderer.styles.scss';
const TOOLTIP_CONTENT_PROPS = {
className: 'field-renderer-tooltip-content',
};
function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
const { dataType, newField, logType } = getFieldAttributes(field);
@@ -19,16 +14,11 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
<span className="field-renderer-container">
{dataType && newField && logType ? (
<>
<TooltipSimple
title={newField}
side="left"
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
<Typography.Text truncate={1} className="label">
{newField}{' '}
</Typography.Text>
</TooltipSimple>
</Tooltip>
<div className="tags">
<TagContainer>

View File

@@ -14,7 +14,7 @@ import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import { removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
@@ -112,7 +112,7 @@ function Overview({
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
value={removeEscapeCharacters(logData.body)}
language="json"
options={options}
onChange={(): void => {}}

View File

@@ -106,20 +106,10 @@ function TableView({
isListViewPanel,
]);
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
// back to a string so flattenObject keeps `body` as a single table row instead of
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
// which would break the tree view in BodyContent that relies on record.field === 'body'.
const flattenLogData: Record<string, string> | null = useMemo(() => {
if (!logData) {
return null;
}
const normalizedLog =
typeof logData.body === 'object' && logData.body !== null
? { ...logData, body: JSON.stringify(logData.body) }
: logData;
return flattenObject(normalizedLog);
}, [logData]);
const flattenLogData: Record<string, string> | null = useMemo(
() => (logData ? flattenObject(logData) : null),
[logData],
);
const handleClick = (
operator: string,

View File

@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string | Record<string, unknown>,
value: string,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
@@ -40,17 +40,11 @@ const useAsyncJSONProcessing = (
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
// Avoid processing if the json is too large
const byteSize = new Blob([value]).size;
if (byteSize > MAX_BODY_BYTES) {
return (): void => {};
}
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
@@ -59,8 +53,8 @@ const useAsyncJSONProcessing = (
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
@@ -88,8 +82,8 @@ const useAsyncJSONProcessing = (
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,

View File

@@ -4,11 +4,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -437,8 +433,3 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}

View File

@@ -20,7 +20,7 @@ const mockUsers: TypesUserDTO[] = [
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
createdAt: '2024-01-01T00:00:00.000Z',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
orgId: 'org-1',
},
{
@@ -28,7 +28,7 @@ const mockUsers: TypesUserDTO[] = [
displayName: 'Bob Jones',
email: 'bob@signoz.io',
status: 'active',
createdAt: '2024-01-02T00:00:00.000Z',
createdAt: new Date('2024-01-02T00:00:00.000Z'),
orgId: 'org-1',
},
{
@@ -36,7 +36,7 @@ const mockUsers: TypesUserDTO[] = [
displayName: '',
email: 'charlie@signoz.io',
status: 'pending_invite',
createdAt: '2024-01-03T00:00:00.000Z',
createdAt: new Date('2024-01-03T00:00:00.000Z'),
orgId: 'org-1',
},
{
@@ -44,7 +44,7 @@ const mockUsers: TypesUserDTO[] = [
displayName: 'Dave Deleted',
email: 'dave@signoz.io',
status: 'deleted',
createdAt: '2024-01-04T00:00:00.000Z',
createdAt: new Date('2024-01-04T00:00:00.000Z'),
orgId: 'org-1',
},
];

View File

@@ -1,11 +1,8 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Spin } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Info } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { HighlightsProps } from './types';
@@ -14,10 +11,6 @@ import {
formatTimestampToReadableDate,
} from './utils';
const TOOLTIP_CONTENT_PROPS = {
className: 'metric-highlights-tooltip-content',
};
function Highlights({ metricName }: HighlightsProps): JSX.Element {
const {
data: metricHighlightsData,
@@ -46,13 +39,6 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
const lastReceivedText = formatTimestampToReadableDate(
metricHighlights?.lastReceived,
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const lastReceivedTooltipText = metricHighlights?.lastReceived
? `Last received on ${formatTimezoneAdjustedTimestamp(
metricHighlights.lastReceived,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
)}`
: 'No data received yet';
if (isErrorMetricHighlights) {
return (
@@ -104,42 +90,27 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
className="metric-details-grid-value"
data-testid="metric-highlights-data-points"
>
<TooltipSimple
title={metricHighlights?.dataPoints?.toLocaleString()}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<span>
{formatNumberIntoHumanReadableFormat(
metricHighlights?.dataPoints ?? 0,
)}
</span>
</TooltipSimple>
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-time-series-total"
>
<TooltipSimple
title="Active time series are those that have received data points in the last 1 hour."
side="top"
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</TooltipSimple>
</Tooltip>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-last-received"
>
<TooltipSimple
title={lastReceivedTooltipText}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<span>{lastReceivedText}</span>
</TooltipSimple>
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
</Typography.Text>
</>
)}

View File

@@ -510,12 +510,6 @@
color: var(--bg-robin-400) !important;
}
// The MetricDetails Drawer sits at z-index 1000; the design-system tooltip
// defaults to z-index 50 and would otherwise render behind the drawer.
.metric-highlights-tooltip-content {
--tooltip-z-index: 1000;
}
@keyframes fade-in-out {
0% {
opacity: 0;

View File

@@ -1,22 +1,10 @@
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import TimezoneProvider from 'providers/Timezone';
import Highlights from '../Highlights';
import { formatTimestampToReadableDate } from '../utils';
import { getMockMetricHighlightsData, MOCK_METRIC_NAME } from './testUtlls';
function renderHighlights(metricName: string): ReturnType<typeof render> {
return render(
<TimezoneProvider>
<TooltipProvider>
<Highlights metricName={metricName} />
</TooltipProvider>
</TimezoneProvider>,
);
}
const useGetMetricHighlightsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricHighlights',
@@ -28,7 +16,7 @@ describe('Highlights', () => {
});
it('should render all highlights data correctly', () => {
renderHighlights(MOCK_METRIC_NAME);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
const dataPoints = screen.getByTestId('metric-highlights-data-points');
const timeSeriesTotal = screen.getByTestId(
@@ -53,7 +41,7 @@ describe('Highlights', () => {
),
);
renderHighlights(MOCK_METRIC_NAME);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(
screen.getByTestId('metric-highlights-error-state'),
@@ -70,7 +58,7 @@ describe('Highlights', () => {
),
);
renderHighlights(MOCK_METRIC_NAME);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();

View File

@@ -1,5 +1,17 @@
.new-explorer-cta-with-badge {
display: inline-flex;
.new-explorer-cta {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px;
}
}

View File

@@ -1,5 +1,9 @@
import ROUTES from 'constants/routes';
export const RIBBON_STYLES = {
top: '-0.75rem',
};
export const buttonText: Record<string, string> = {
[ROUTES.LOGS_EXPLORER]: 'Old Explorer',
[ROUTES.TRACE]: 'New Explorer',

View File

@@ -1,13 +1,12 @@
import React, { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Badge, Button } from 'antd';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Undo } from '@signozhq/icons';
import { isModifierKeyPressed } from 'utils/app';
import { buttonText } from './config';
import { buttonText, RIBBON_STYLES } from './config';
import './NewExplorerCTA.styles.scss';
@@ -71,12 +70,9 @@ function NewExplorerCTA(): JSX.Element | null {
}
return (
<span className="new-explorer-cta-with-badge">
<Badge.Ribbon style={RIBBON_STYLES} text="New">
{button}
<Badge color="robin" variant="default">
New
</Badge>
</span>
</Badge.Ribbon>
);
}

View File

@@ -1,53 +0,0 @@
import { SolidAlertTriangle } from '@signozhq/icons';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
export interface DiscardChangesModalProps {
open: boolean;
isNewPanel: boolean;
panelTitle?: string;
dashboardTitle?: string;
onDiscard: () => void;
onClose: () => void;
}
export default function DiscardChangesModal({
open,
isNewPanel,
panelTitle,
dashboardTitle,
onDiscard,
onClose,
}: DiscardChangesModalProps): JSX.Element {
const dashboardName = dashboardTitle ? (
<>
{' '}
to <strong>{dashboardTitle}</strong>
</>
) : null;
const panelLabel = panelTitle ? <strong>{panelTitle}</strong> : 'this panel';
return (
<ConfirmDialog
open={open}
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={onDiscard}
onCancel={onClose}
>
{isNewPanel ? (
<Typography>This new panel won&apos;t be added{dashboardName}.</Typography>
) : (
<Typography>Your unsaved edits to {panelLabel} will be lost.</Typography>
)}
</ConfirmDialog>
);
}

View File

@@ -1,17 +1,13 @@
import {
initialAutocompleteData,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import type { PartialPanelTypes } from '../utils';
import { getIsQueryModified, handleQueryChange } from '../utils';
import { handleQueryChange } from '../utils';
const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
queryType: EQueryType.QUERY_BUILDER,
@@ -41,128 +37,6 @@ const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
},
});
const buildMetricsQuery = (
overrides?: Partial<{
metricName: string;
aggregateAttributeKey: string;
legend: string;
groupByKey: string;
}>,
): Query => ({
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
id: 'query-id',
unit: '',
builder: {
queryFormulas: [],
queryData: [
{
...initialQueryBuilderFormValuesMap[DataSource.METRICS],
queryName: 'A',
aggregateAttribute: overrides?.aggregateAttributeKey
? {
...initialAutocompleteData,
key: overrides.aggregateAttributeKey,
type: 'tag',
dataType: DataTypes.Float64,
}
: cloneDeep(initialAutocompleteData),
aggregations: [
{
metricName: overrides?.metricName ?? 'system.cpu.load',
temporality: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
reduceTo: 'avg',
} as MetricAggregation,
],
legend: overrides?.legend ?? '',
groupBy: overrides?.groupByKey
? [
{
...initialAutocompleteData,
key: overrides.groupByKey,
type: 'tag',
dataType: DataTypes.String,
},
]
: [],
},
],
queryTraceOperator: [],
},
});
describe('getIsQueryModified', () => {
it('returns false when baseline is null (new unsaved panel with no edits anchor)', () => {
const current = buildMetricsQuery();
expect(getIsQueryModified(current, null)).toBe(false);
});
it('returns false when baseline is undefined', () => {
const current = buildMetricsQuery();
expect(getIsQueryModified(current, undefined)).toBe(false);
});
it('returns false when current only differs by auto-backfilled aggregateAttribute', () => {
// saved widget query: aggregateAttribute is the v5-style empty initial value
// (stripped from persisted spec; spread back in as initialAutocompleteData on load)
const savedQuery = buildMetricsQuery({ metricName: 'system.cpu.load' });
// after MetricNameSelector edit-mode backfill, currentQuery has the populated
// aggregateAttribute while the rest of the query is identical
const currentQuery = buildMetricsQuery({
metricName: 'system.cpu.load',
aggregateAttributeKey: 'system.cpu.load',
});
expect(getIsQueryModified(currentQuery, savedQuery)).toBe(false);
});
it('returns true when the user edits the legend', () => {
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
const edited = buildMetricsQuery({
metricName: 'system.cpu.load',
legend: 'cpu-load',
});
expect(getIsQueryModified(edited, baseline)).toBe(true);
});
it('returns true when the user picks a different metric (aggregations diverges)', () => {
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
const edited = buildMetricsQuery({ metricName: 'system.memory.usage' });
expect(getIsQueryModified(edited, baseline)).toBe(true);
});
it('returns true when the user adds a groupBy', () => {
const baseline = buildMetricsQuery({ metricName: 'system.cpu.load' });
const edited = buildMetricsQuery({
metricName: 'system.cpu.load',
groupByKey: 'host.name',
});
expect(getIsQueryModified(edited, baseline)).toBe(true);
});
it('returns true on existing widget when current diverges from saved (Stage-and-Run silent-loss flow)', () => {
// After Edit → Stage and Run, stagedQuery is reset to match currentQuery.
// The dirty check must compare against the SAVED widget query, not stagedQuery.
const savedQuery = buildMetricsQuery({ metricName: 'system.cpu.load' });
const currentQuery = buildMetricsQuery({ metricName: 'system.memory.usage' });
expect(getIsQueryModified(currentQuery, savedQuery)).toBe(true);
});
it('returns false for a new panel where currentQuery still matches stagedQuery baseline', () => {
const stagedQuery = buildMetricsQuery();
const currentQuery = buildMetricsQuery();
expect(getIsQueryModified(currentQuery, stagedQuery)).toBe(false);
});
it('returns true for a new panel where currentQuery has been edited away from stagedQuery', () => {
const stagedQuery = buildMetricsQuery();
const currentQuery = buildMetricsQuery({ legend: 'custom' });
expect(getIsQueryModified(currentQuery, stagedQuery)).toBe(true);
});
});
describe('handleQueryChange', () => {
it('sets list-specific fields when switching to LIST', () => {
const superset = buildSupersetQuery();

View File

@@ -1,17 +1,18 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { generatePath } from 'react-router-dom';
import { Check, X } from '@signozhq/icons';
import { Check, SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/ui/resizable';
import { Flex } from 'antd';
import { Flex, Modal, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
@@ -68,6 +69,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType, getGraphTypeForFormat } from 'utils/getGraphType';
import LeftContainer from './LeftContainer';
import QueryTypeTag from './LeftContainer/QueryTypeTag';
import RightContainer from './RightContainer';
import { ThresholdProps } from './RightContainer/Threshold/types';
import TimeItems, { timePreferance } from './RightContainer/timeItems';
@@ -80,7 +82,6 @@ import {
placeWidgetAtBottom,
placeWidgetBetweenRows,
} from './utils';
import DiscardChangesModal from './WidgetModals/DiscardChangesModal';
import './NewWidget.styles.scss';
@@ -97,6 +98,8 @@ function NewWidget({
const { dashboardVariables } = useDashboardVariables();
const { t } = useTranslation(['dashboard']);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const {
@@ -107,6 +110,11 @@ function NewWidget({
setSupersetQuery,
} = useQueryBuilder();
const isQueryModified = useMemo(
() => getIsQueryModified(currentQuery, stagedQuery),
[currentQuery, stagedQuery],
);
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -131,23 +139,6 @@ function NewWidget({
const query = useUrlQuery();
// For existing widgets, compare currentQuery against the saved widget query
// (stable across Stage-and-Run cycles). For new panels with no saved baseline,
// fall back to stagedQuery so initial edits still trigger the warning.
const savedWidgetQuery = useMemo(() => {
const widgetId = query.get('widgetId');
const match = widgets?.find((w) => w.id === widgetId);
if (!match || match.panelTypes === PANEL_GROUP_TYPES.ROW) {
return null;
}
return (match as Widgets).query ?? null;
}, [widgets, query]);
const isQueryModified = useMemo(
() => getIsQueryModified(currentQuery, savedWidgetQuery ?? stagedQuery),
[currentQuery, savedWidgetQuery, stagedQuery],
);
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
const logEventCalledRef = useRef(false);
@@ -237,6 +228,7 @@ function NewWidget({
Record<string, string>
>(selectedWidget?.customLegendColors || {});
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
const [bucketWidth, setBucketWidth] = useState<number>(
@@ -348,6 +340,7 @@ function NewWidget({
]);
const closeModal = (): void => {
setSaveModal(false);
setDiscardModal(false);
};
@@ -600,7 +593,7 @@ function NewWidget({
},
};
return updateDashboardMutation.mutateAsync(dashboard, {
updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || '');
navigateToDashboardPage();
@@ -695,9 +688,9 @@ function NewWidget({
})),
}),
});
onClickSaveHandler();
setSaveModal(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onClickSaveHandler]);
}, [isNewPanel]);
const isNewTraceLogsAvailable =
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
@@ -958,14 +951,57 @@ function NewWidget({
</ResizablePanel>
</ResizablePanelGroup>
</PanelContainer>
<DiscardChangesModal
<Modal
title={
isQueryModified ? (
<Space>
<SolidAlertTriangle size={16} color="#fdd600" />
Unsaved Changes
</Space>
) : (
'Save Widget'
)
}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={closeModal}
onOk={onClickSaveHandler}
confirmLoading={updateDashboardMutation.isLoading}
centered
open={saveModal}
width={600}
>
{!isQueryModified ? (
<Typography>
{t('your_graph_build_with')}{' '}
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
{t('dashboard_ok_confirm')}
</Typography>
) : (
<Typography>{t('dashboard_unsave_changes')} </Typography>
)}
</Modal>
<Modal
title={
<Space>
<SolidAlertTriangle size={16} color="#fdd600" />
Unsaved Changes
</Space>
}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={closeModal}
onOk={discardChanges}
centered
open={discardModal}
isNewPanel={isNewPanel}
panelTitle={title}
dashboardTitle={dashboardData?.data?.title}
onDiscard={discardChanges}
onClose={closeModal}
/>
width={600}
>
<Typography>{t('dashboard_unsave_changes')}</Typography>
</Modal>
</Container>
);
}

View File

@@ -1,4 +1,5 @@
import { Layout } from 'react-grid-layout';
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
import {
@@ -25,84 +26,16 @@ import { DataSource } from 'types/common/queryBuilder';
import { getCategoryName } from './RightContainer/dataFormatCategories';
// Asks "would saving the current panel change the persisted widget spec?".
//
// `adjustQueryForV5` is deliberately not reused here: in addition to stripping
// the legacy v4 fields, it also resurrects them onto each metric
// `aggregations[i]`. That migration step is correct on save but bleeds
// asymmetrically across a comparator — the live query still carries the
// legacy defaults from `initialQueryBuilderFormValuesMap` while a previously
// saved widget had them stripped.
const stripQueryDataForCompare = (
queryData: IBuilderQuery,
): Record<string, unknown> => {
const {
aggregateAttribute: _aggregateAttribute,
aggregateOperator: _aggregateOperator,
timeAggregation: _timeAggregation,
spaceAggregation: _spaceAggregation,
reduceTo: _reduceTo,
filters: _filters,
...retained
} = queryData ?? ({} as IBuilderQuery);
const groupBy = (retained.groupBy ?? []).map((entry) => {
const { id: _id, ...rest } = entry;
return rest;
});
return {
...retained,
groupBy,
source: retained.source || '',
};
};
const normalizeForDirtyCheck = (query: Query): Record<string, unknown> => {
const { id: _id, unit, builder, ...rest } = query;
return {
...rest,
// `id` is regenerated on every Stage and Run; `unit` flips between ''
// and undefined depending on whether the user has touched the selector.
unit: unit || '',
builder: {
...builder,
queryData: (builder?.queryData ?? []).map(stripQueryDataForCompare),
},
};
};
// `lodash.isEqual` distinguishes `{a: undefined}` from `{}`; for the dirty
// check those are the same. Initial-values spreads on the live query
// frequently leave such explicit-undefined keys.
const stripUndefined = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(stripUndefined);
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
if (v === undefined) {
return;
}
out[k] = stripUndefined(v);
});
return out;
}
return value;
};
export const getIsQueryModified = (
currentQuery: Query,
baselineQuery: Query | null | undefined,
stagedQuery: Query | null,
): boolean => {
if (!baselineQuery) {
if (!stagedQuery) {
return false;
}
return !isEqual(
stripUndefined(normalizeForDirtyCheck(baselineQuery)),
stripUndefined(normalizeForDirtyCheck(currentQuery)),
);
const omitIdFromStageQuery = omitIdFromQuery(stagedQuery);
const omitIdFromCurrentQuery = omitIdFromQuery(currentQuery);
return !isEqual(omitIdFromStageQuery, omitIdFromCurrentQuery);
};
export type PartialPanelTypes = {

View File

@@ -4,15 +4,12 @@ import amazonMskUrl from '@/assets/Logos/amazon-msk.svg';
import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg';
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
import apiGatewayUrl from '@/assets/Logos/api-gateway.svg';
import argocdUrl from '@/assets/Logos/argocd.svg';
import aspnetUrl from '@/assets/Logos/aspnet.svg';
import autogenUrl from '@/assets/Logos/autogen.svg';
import awsAlbUrl from '@/assets/Logos/aws-alb.svg';
import azureAppServiceUrl from '@/assets/Logos/azure-app-service.svg';
import azureBlobStorageUrl from '@/assets/Logos/azure-blob-storage.svg';
import azureCdnFrontdoorUrl from '@/assets/Logos/azure-cdn-frontdoor.svg';
import azureContainerAppsUrl from '@/assets/Logos/azure-container-apps.svg';
import azureFunctionsUrl from '@/assets/Logos/azure-functions.svg';
import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
@@ -21,7 +18,6 @@ import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metric
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
@@ -68,7 +64,6 @@ import goUrl from '@/assets/Logos/go.svg';
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
import googleGeminiUrl from '@/assets/Logos/google-gemini.svg';
import grafanaUrl from '@/assets/Logos/grafana.svg';
import graphqlUrl from '@/assets/Logos/graphql.svg';
import grokUrl from '@/assets/Logos/grok.svg';
import groqUrl from '@/assets/Logos/groq.svg';
import hasuraUrl from '@/assets/Logos/hasura.svg';
@@ -80,7 +75,6 @@ import httpUrl from '@/assets/Logos/http.svg';
import httpMonitoringUrl from '@/assets/Logos/http-monitoring.svg';
import huggingfaceUrl from '@/assets/Logos/huggingface.svg';
import inkeepUrl from '@/assets/Logos/inkeep.svg';
import istioUrl from '@/assets/Logos/istio.svg';
import javaUrl from '@/assets/Logos/java.svg';
import javaOthersUrl from '@/assets/Logos/java-others.svg';
import javascriptUrl from '@/assets/Logos/javascript.svg';
@@ -127,7 +121,6 @@ import pythonUrl from '@/assets/Logos/python.svg';
import quarkusUrl from '@/assets/Logos/quarkus.svg';
import quickstartUrl from '@/assets/Logos/quickstart.svg';
import qwenUrl from '@/assets/Logos/qwen.svg';
import railwayUrl from '@/assets/Logos/railway.svg';
import rdsUrl from '@/assets/Logos/rds.svg';
import reactjsUrl from '@/assets/Logos/reactjs.svg';
import redisUrl from '@/assets/Logos/redis.svg';
@@ -135,9 +128,7 @@ import renderUrl from '@/assets/Logos/render.svg';
import rubyOnRailsUrl from '@/assets/Logos/ruby-on-rails.svg';
import rustUrl from '@/assets/Logos/rust.svg';
import s3Url from '@/assets/Logos/s3.svg';
import scalaUrl from '@/assets/Logos/scala.svg';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import slogUrl from '@/assets/Logos/slog.svg';
import slurmUrl from '@/assets/Logos/slurm.svg';
import snowflakeUrl from '@/assets/Logos/snowflake.svg';
import snsUrl from '@/assets/Logos/sns.svg';
@@ -3011,18 +3002,9 @@ const onboardingConfigWithLinks = [
'tracing',
],
question: {
desc: 'How would you like to set up Azure Blob Storage monitoring?',
desc: 'What telemetry data do you want to visualise ?',
type: 'select',
helpText:
'One Click uses Azure integration for automated setup. Manual setup uses OpenTelemetry for more control.',
options: [
{
key: 'azure-blob-storage-one-click',
label: 'One Click Azure',
imgUrl: azureBlobStorageUrl,
link: '/integrations/azure?service=storageaccountsblob',
internalRedirect: true,
},
{
key: 'logging',
label: 'Logs',
@@ -3038,32 +3020,6 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'azure-cdn-frontdoor',
label: 'Azure CDN / Front Door',
imgUrl: azureCdnFrontdoorUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cdn',
'azure cdn frontdoor',
'azure cdn metrics',
'azure cdn monitoring',
'azure front door',
'azure frontdoor',
'cdn',
'cdn monitoring',
'cdn observability',
'content delivery network',
'front door',
'frontdoor',
'one click',
],
id: 'azure-cdn-frontdoor',
link: '/integrations/azure?service=cdnprofile',
internalRedirect: true,
},
{
dataSource: 'azure-mysql-flexible-server',
label: 'Azure MySQL Flexible Server',
@@ -5658,22 +5614,17 @@ const onboardingConfigWithLinks = [
dataSource: 'fly-io',
label: 'Fly.io',
imgUrl: flyIoUrl,
tags: ['infrastructure monitoring', 'metrics', 'logs'],
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'cloud',
'fly',
'fly.io',
'fly.io logs',
'fly.io metrics',
'fly.io monitoring',
'fly.io observability',
'infrastructure',
'logs',
'fly',
'metrics',
'infrastructure',
'cloud',
'monitoring',
],
link: '/docs/integrations/flyio/',
link: '/docs/metrics-management/fly-metrics/',
},
{
dataSource: 'envoy',
@@ -6295,194 +6246,5 @@ const onboardingConfigWithLinks = [
id: 'render-metrics',
link: '/docs/metrics-management/render-metrics/',
},
{
dataSource: 'cert-manager',
label: 'Cert Manager',
imgUrl: certManagerUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'cert manager',
'cert-manager',
'certificate',
'certificate management',
'certificate monitoring',
'infrastructure',
'kubernetes',
'kubernetes certificates',
'metrics',
'monitoring',
'observability',
'ssl',
'tls',
],
id: 'cert-manager',
link: '/docs/infrastructure-monitoring/cert-manager/',
},
{
dataSource: 'graphql',
label: 'GraphQL',
imgUrl: graphqlUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'api',
'graphql',
'graphql instrumentation',
'graphql monitoring',
'graphql observability',
'graphql tracing',
'javascript',
'monitoring',
'nodejs',
'observability',
'opentelemetry graphql',
'traces',
'tracing',
],
id: 'graphql',
link: '/docs/instrumentation/javascript/opentelemetry-graphql/',
},
{
dataSource: 'railway',
label: 'Railway',
imgUrl: railwayUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'cloud',
'log forwarding',
'logging',
'logs',
'monitoring',
'observability',
'paas',
'railway',
'railway logs',
'railway monitoring',
'railway observability',
],
id: 'railway',
link: '/docs/integrations/outposts/railway/',
},
{
dataSource: 'aspnet-core-metrics',
label: 'ASP.NET Core Metrics',
imgUrl: aspnetUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'.net metrics',
'asp.net',
'asp.net core',
'asp.net core metrics',
'asp.net metrics',
'asp.net monitoring',
'asp.net observability',
'aspnet',
'aspnet core',
'dotnet metrics',
'metrics',
'monitoring',
'observability',
'opentelemetry aspnet',
],
id: 'aspnet-core-metrics',
link:
'/docs/metrics-management/send-metrics/applications/opentelemetry-aspnetcore/',
},
{
dataSource: 'istio-metrics',
label: 'Istio',
imgUrl: istioUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'infrastructure',
'istio',
'istio metrics',
'istio monitoring',
'istio observability',
'kubernetes',
'mesh',
'metrics',
'monitoring',
'observability',
'service mesh',
],
id: 'istio-metrics',
link: '/docs/metrics-management/istio-metrics/',
},
{
dataSource: 'slog',
label: 'log/slog',
imgUrl: slogUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'go',
'go logging',
'go logs',
'golang',
'golang logging',
'log/slog',
'logging',
'logs',
'monitoring',
'observability',
'slog',
'slog instrumentation',
'slog logging',
'structured logging',
],
id: 'slog',
link: '/docs/logs-management/send-logs/slog-to-signoz/',
},
{
dataSource: 'scala',
label: 'Scala',
imgUrl: scalaUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'instrumentation',
'jvm',
'monitoring',
'observability',
'opentelemetry scala',
'scala',
'scala instrumentation',
'scala monitoring',
'scala observability',
'scala tracing',
'traces',
'tracing',
],
id: 'scala',
link: '/docs/instrumentation/java/opentelemetry-scala/',
},
{
dataSource: 'apache-druid',
label: 'Apache Druid',
imgUrl: apacheDruidUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'analytics',
'apache druid',
'database',
'druid',
'druid instrumentation',
'druid monitoring',
'druid observability',
'monitoring',
'observability',
'olap',
'opentelemetry druid',
],
id: 'apache-druid',
link: '/docs/integrations/opentelemetry-apache-druid/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -2,7 +2,6 @@ import { Expand } from '@signozhq/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -27,9 +26,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
)}
</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
</div>
<div className="logs-preview-list-item-body">{log.body}</div>
<div
className="logs-preview-list-item-expand"
onClick={makeLogDetailsHandler(log)}

View File

@@ -81,6 +81,20 @@
}
}
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
}
}
.alert-rule-all-warning {
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.formItemWithBullet {
margin-bottom: 0;
}

View File

@@ -24,7 +24,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
import { PlannedDowntimeList } from './PlannedDowntimeList';
import {
defaultInitialValues,
defautlInitialValues,
deleteDowntimeHandler,
} from './PlannedDowntimeutils';
@@ -48,9 +48,9 @@ export function PlannedDowntime(): JSX.Element {
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] =
useState<Partial<AlertmanagertypesPlannedMaintenanceDTO>>(
defaultInitialValues,
);
useState<
Partial<AlertmanagertypesPlannedMaintenanceDTO & { editMode: boolean }>
>(defautlInitialValues);
const downtimeSchedules = useListDowntimeSchedules();
const alertOptions = React.useMemo(
@@ -148,7 +148,7 @@ export function PlannedDowntime(): JSX.Element {
<Button
type="primary"
onClick={(): void => {
setInitialValues(defaultInitialValues);
setInitialValues({ ...defautlInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -8,9 +8,11 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -46,6 +48,8 @@ import { AlertRuleTags } from './PlannedDowntimeList';
import {
getAlertOptionsFromIds,
getDurationInfo,
getEndTime,
handleTimeConversion,
isScheduleRecurring,
recurrenceOptions,
recurrenceOptionWithSubmenu,
@@ -62,28 +66,28 @@ const TIME_FORMAT = DATE_TIME_FORMATS.TIME;
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATE;
const ORDINAL_FORMAT = DATE_TIME_FORMATS.ORDINAL_ONLY;
const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
(timezone: string) => ({
label: timezone,
value: timezone,
key: timezone,
}),
);
type AlertRuleScope = 'all' | 'specific';
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
recurrence?: AlertmanagertypesRecurrenceDTO;
startTime: dayjs.Dayjs | string;
endTime: dayjs.Dayjs | string;
recurrence?: AlertmanagertypesRecurrenceDTO | null;
alertRuleScope: AlertRuleScope;
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
timezone?: string;
labelExpression?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: Partial<AlertmanagertypesPlannedMaintenanceDTO>;
initialValues: Partial<
AlertmanagertypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>;
alertOptions: DefaultOptionType[];
isError: boolean;
isLoading: boolean;
@@ -91,7 +95,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance;
form: FormInstance<any>;
}
export function PlannedDowntimeForm(
@@ -109,46 +113,76 @@ export function PlannedDowntimeForm(
form,
} = props;
const [selectedTags, setSelectedTags] = React.useState<DefaultOptionType[]>(
[],
);
const [selectedTags, setSelectedTags] = React.useState<
DefaultOptionType | DefaultOptionType[]
>([]);
const alertRuleFormName = 'alertRules';
const [saveLoading, setSaveLoading] = useState(false);
const [durationUnit, setDurationUnit] = useState<string>(
getDurationInfo(initialValues.schedule?.recurrence?.duration)?.unit || 'm',
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.unit || 'm',
);
const [formData, setFormData] = useState<Partial<PlannedDowntimeFormData>>({
timezone: initialValues.schedule?.timezone,
});
const [recurrenceType, setRecurrenceType] = useState<string>(
initialValues.schedule?.recurrence?.repeatType ||
const [recurrenceType, setRecurrenceType] = useState<string | null>(
(initialValues.schedule?.recurrence?.repeatType as string) ||
recurrenceOptions.doesNotRepeat.value,
);
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
initialValues.id && (initialValues.alertIds || []).length === 0
? 'all'
: 'specific',
);
const timezoneInitialValue = !isEmpty(initialValues.schedule?.timezone)
? (initialValues.schedule?.timezone as string)
: undefined;
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const requiredFieldRule = [{ required: true }];
const datePickerFooter = (mode: any): any =>
mode === 'time' ? (
<span style={{ color: 'gray' }}>Please select the time</span>
) : null;
const saveHandler = useCallback(
const saveHanlder = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
alertIds:
values.alertRuleScope === 'all'
? []
: (values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[]),
name: values.name,
labelExpression: values.labelExpression,
schedule: {
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone: values.timezone!,
recurrence: values.recurrence,
startTime: new Date(
handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
),
timezone: values.timezone as string,
endTime: values.endTime
? new Date(
handleTimeConversion(
values.endTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
)
: undefined,
recurrence: values.recurrence as AlertmanagertypesRecurrenceDTO,
},
};
@@ -180,58 +214,50 @@ export function PlannedDowntimeForm(
notifications,
refetchAllSchedules,
setIsOpen,
timezoneInitialValue,
showErrorModal,
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
duration: values.recurrence?.duration
? `${values.recurrence?.duration}${durationUnit}`
: undefined,
endTime: !isEmpty(values.endTime)
? handleTimeConversion(
values.endTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
)
: undefined,
startTime: handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
),
repeatOn: !values.recurrence?.repeatOn?.length
? undefined
: values.recurrence?.repeatOn,
repeatType: values.recurrence?.repeatType,
};
await saveHandler({
const payloadValues = {
...values,
recurrence: recurrenceData,
});
recurrence: recurrenceData as AlertmanagertypesRecurrenceDTO | undefined,
};
await saveHanlder(payloadValues);
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
const { startTime, endTime, timezone } = data;
const update: Partial<PlannedDowntimeFormData> = {};
// If the set timezone doesn't match, update it.
if (
startTime &&
timezone &&
startTime.format() !== startTime.tz(timezone, true).format()
) {
update.startTime = startTime.tz(timezone, true);
}
if (
endTime &&
timezone &&
endTime.format() !== endTime.tz(timezone, true).format()
) {
update.endTime = endTime.tz(timezone, true);
}
if (!isEmpty(update)) {
data = { ...data, ...update };
form.setFieldsValue({ ...update });
}
setFormData(data);
};
const formValidationRules = [
{
required: true,
},
];
const handleOk = async (): Promise<void> => {
await form.validateFields().catch(() => {
@@ -239,11 +265,16 @@ export function PlannedDowntimeForm(
});
};
const handleCancel = (): void => setIsOpen(false);
const handleCancel = (): void => {
setIsOpen(false);
};
const handleAlertRulesChange: SelectProps['onChange'] = (_value, options) => {
const handleChange = (
_value: string,
options: DefaultOptionType | DefaultOptionType[],
): void => {
form.setFieldValue(alertRuleFormName, options);
setSelectedTags(Array.isArray(options) ? options : [options]);
setSelectedTags(options);
};
const noTagRenderer: SelectProps['tagRender'] = () => <></>;
@@ -252,51 +283,116 @@ export function PlannedDowntimeForm(
if (!removedTag) {
return;
}
const newTags = selectedTags.filter((tag) => tag.value !== removedTag);
const newTags = selectedTags.filter(
(tag: DefaultOptionType) => tag.value !== removedTag,
);
form.setFieldValue(alertRuleFormName, newTags);
setSelectedTags(newTags);
};
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
return {
const formatedInitialValues = useMemo(() => {
const initialAlertIds = initialValues.alertIds || [];
const isExistingSchedule = Boolean(initialValues.id);
const formData: PlannedDowntimeFormData = {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
alertRuleScope:
isExistingSchedule && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
startTime: initialValues.schedule?.startTime
? dayjs(initialValues.schedule?.startTime)
: '',
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues?.schedule)
? recurrenceOptions.doesNotRepeat.value
: schedule?.recurrence?.repeatType,
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
: initialValues.schedule?.recurrence
?.repeatType) as AlertmanagertypesRecurrenceDTO['repeatType'],
duration: String(
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.value ?? '',
),
} as AlertmanagertypesRecurrenceDTO,
timezone: schedule?.timezone as string,
timezone: initialValues.schedule?.timezone as string,
labelExpression: initialValues.labelExpression || '',
};
return formData;
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
form.setFieldsValue({ ...formattedInitialValues });
}, [form, formattedInitialValues, initialValues]);
setSelectedTags(formatedInitialValues.alertRules);
setAlertRuleScope(formatedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formatedInitialValues });
}, [form, formatedInitialValues, initialValues]);
const timeZoneItems: DefaultOptionType[] = ALL_TIME_ZONES.map(
(timezone: string) => ({
label: timezone,
value: timezone,
key: timezone,
}),
);
const getTimezoneFormattedTime = (
time: string | dayjs.Dayjs,
timeZone?: string,
isEditMode?: boolean,
format?: string,
): string => {
if (!time) {
return '';
}
if (!timeZone) {
return dayjs(time).format(format);
}
return dayjs(time).tz(timeZone, isEditMode).format(format);
};
const startTimeText = useMemo((): string => {
const startTime = formData.startTime;
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
}
if (!startTime) {
return '';
}
const daysOfWeek = formData.recurrence?.repeatOn;
if (formData.timezone) {
startTime = handleTimeConversion(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
const formattedStartTime = startTime.format(TIME_FORMAT);
const formattedStartDate = startTime.format(DATE_FORMAT);
const ordinalFormat = startTime.format(ORDINAL_FORMAT);
const formattedStartTime = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedStartDate = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
const ordinalFormat = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
ORDINAL_FORMAT,
);
const formattedDaysOfWeek = daysOfWeek?.join(', ');
switch (recurrenceType) {
@@ -311,18 +407,49 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
const endTimeText = useMemo((): string => {
const endTime = formData.endTime;
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime =
(formData?.recurrence?.endTime
? dayjs(formData.recurrence.endTime).toISOString()
: '') || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';
}
}
if (!endTime) {
return '';
}
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
if (formData.timezone) {
endTime = handleTimeConversion(
endTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const formattedEndTime = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedEndDate = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
return (
<Modal
@@ -338,28 +465,34 @@ export function PlannedDowntimeForm(
footer={null}
>
<Form<PlannedDowntimeFormData>
name={isEditMode ? 'edit-form' : 'create-form'}
name={initialValues.editMode ? 'edit-form' : 'create-form'}
form={form}
layout="vertical"
className="createForm"
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
handleFormData(form.getFieldsValue());
setAlertRuleScope(form.getFieldValue('alertRuleScope') as AlertRuleScope);
setFormData(form.getFieldsValue());
}}
autoComplete="off"
>
<Form.Item label="Name" name="name" rules={requiredFieldRule}>
<Form.Item label="Name" name="name" rules={formValidationRules}>
<Input placeholder="e.g. Upgrade downtime" />
</Form.Item>
<Form.Item
label="Starts from"
name="startTime"
rules={requiredFieldRule}
rules={formValidationRules}
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
<DatePicker
format={(date) => date.format(customFormat)}
format={(date): string =>
dayjs(date).tz(timezoneInitialValue).format(customFormat)
}
showTime
renderExtraFooter={datePickerFooter}
showNow={false}
@@ -372,7 +505,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Repeats every"
name={['recurrence', 'repeatType']}
rules={requiredFieldRule}
rules={formValidationRules}
>
<Select
placeholder="Select option..."
@@ -383,7 +516,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Weekly occurernce"
name={['recurrence', 'repeatOn']}
rules={requiredFieldRule}
rules={formValidationRules}
>
<Select
placeholder="Select option..."
@@ -397,14 +530,16 @@ export function PlannedDowntimeForm(
<Form.Item
label="Duration"
name={['recurrence', 'duration']}
rules={requiredFieldRule}
rules={formValidationRules}
>
<Input
addonAfter={
<Select
defaultValue="m"
value={durationUnit}
onChange={(value): void => setDurationUnit(value)}
onChange={(value): void => {
setDurationUnit(value);
}}
>
<Select.Option value="m">Mins</Select.Option>
<Select.Option value="h">Hours</Select.Option>
@@ -418,8 +553,8 @@ export function PlannedDowntimeForm(
/>
</Form.Item>
)}
<Form.Item label="Timezone" name="timezone" rules={requiredFieldRule}>
<Select options={TZ_OPTIONS} placeholder="Select timezone" showSearch />
<Form.Item label="Timezone" name="timezone" rules={formValidationRules}>
<Select options={timeZoneItems} placeholder="Select timezone" showSearch />
</Form.Item>
<Form.Item
label="Ends on"
@@ -431,9 +566,14 @@ export function PlannedDowntimeForm(
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
<DatePicker
format={(date) => date.format(customFormat)}
format={(date): string =>
dayjs(date).tz(timezoneInitialValue).format(customFormat)
}
showTime
showNow={false}
renderExtraFooter={datePickerFooter}
@@ -444,50 +584,101 @@ export function PlannedDowntimeForm(
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div>
<div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
(Leave empty to silence all alerts)
</Typography>
</div>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Form.Item
name="alertRuleScope"
initialValue="specific"
className="alert-rule-scope"
>
<Radio.Group>
<Radio value="all">All alert rules</Radio>
<Radio value="specific">Specific alert rules</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name={alertRuleFormName}>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
{alertRuleScope === 'specific' && (
<>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item
name={alertRuleFormName}
rules={[
{
validator: async (
_rule,
value: DefaultOptionType[] | undefined,
): Promise<void> => {
if (!value || value.length === 0) {
throw new Error(
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
);
}
},
},
]}
>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)
?.toLowerCase()
?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
{alertRuleScope === 'all' && (
<Typography
className="alert-rule-info alert-rule-all-warning"
style={{ marginBottom: 16 }}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
All alerts will be silenced for the duration of this maintenance window.
</Typography>
)}
</div>
<Form.Item
label={
<span>
Label Expression&nbsp;
<Tooltip title='Filter by alert labels. Examples: env == "prod", region == "us-east-1" && severity == "critical"'>
<Info size={13} />
</Tooltip>
</span>
}
name="labelExpression"
>
<Input.TextArea
placeholder='e.g. env == "prod" && region == "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Space, Table, TableProps, Tag, Tooltip } from 'antd';
@@ -8,7 +8,7 @@ import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
AlertmanagertypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import cx from 'classnames';
@@ -19,11 +19,12 @@ import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { showErrorNotification } from 'utils/error';
import { showErrorNotification } from '../../utils/error';
import {
formatDateTime,
getAlertOptionsFromIds,
getDuration,
getEndTime,
recurrenceInfo,
} from './PlannedDowntimeutils';
@@ -125,28 +126,29 @@ export function CollapseListContent({
created_at,
created_by_name,
created_by_email,
schedule,
timeframe,
repeats,
updated_at,
updated_by_name,
alertOptions,
timezone,
}: {
created_at?: string;
created_by_name?: string;
created_by_email?: string;
schedule?: AlertmanagertypesScheduleDTO;
timeframe: [string | undefined | null, string | undefined | null];
repeats?: AlertmanagertypesRecurrenceDTO | null;
updated_at?: string;
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
timezone?: string;
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
<div className="render-item-value">{value}</div>
</div>
);
const startTime = formatDateTime(schedule?.startTime, schedule?.timezone);
const endTime = formatDateTime(schedule?.endTime, schedule?.timezone);
return (
<Flex vertical>
@@ -181,20 +183,16 @@ export function CollapseListContent({
{renderItems(
'Timeframe',
schedule?.startTime ? (
<Typography>{`${startTime}${endTime}`}</Typography>
timeframe[0] || timeframe[1] ? (
<Typography>{`${formatDateTime(timeframe[0])}${formatDateTime(
timeframe[1],
)}`}</Typography>
) : (
'-'
),
)}
{renderItems(
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -204,7 +202,7 @@ export function CollapseListContent({
selectedTags={alertOptions}
/>
) : (
'-'
<Tag className="all-alerts-tag">All alert rules</Tag>
),
)}
</Flex>
@@ -234,12 +232,22 @@ export function CustomCollapseList(
setModalOpen,
handleDeleteDowntime,
setEditMode,
kind,
} = props;
const scheduleTime = schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: createdAt || '';
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(scheduleTime)} ${schedule?.timezone}`;
? dayjs(schedule.startTime).toISOString()
: createdAt
? dayjs(createdAt).toISOString()
: '';
// Combine time and date
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(
defaultTo(scheduleTime, ''),
)} ${schedule?.timezone}`;
const endTime = getEndTime({
kind,
schedule,
} as Partial<AlertmanagertypesPlannedMaintenanceDTO>);
return (
<>
@@ -249,16 +257,21 @@ export function CustomCollapseList(
<HeaderComponent
duration={
schedule?.recurrence?.duration
? schedule.recurrence.duration
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
? (schedule?.recurrence?.duration as string)
: getDuration(
schedule?.startTime ? dayjs(schedule.startTime).toISOString() : '',
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '',
)
}
name={defaultTo(name, '')}
handleEdit={() => {
handleEdit={(): void => {
setInitialValues({ ...props });
setModalOpen(true);
setEditMode(true);
}}
handleDelete={() => handleDeleteDowntime(id ?? '', name || '')}
handleDelete={(): void => {
handleDeleteDowntime(id ?? '', name || '');
}}
/>
}
key={id ?? ''}
@@ -266,10 +279,17 @@ export function CustomCollapseList(
<CollapseListContent
created_at={createdAt ? dayjs(createdAt).toISOString() : ''}
created_by_name={defaultTo(createdBy, '')}
schedule={schedule}
timeframe={[
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={
schedule?.recurrence as AlertmanagertypesRecurrenceDTO | null | undefined
}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
timezone={defaultTo(schedule?.timezone, '')}
/>
</Panel>
</Collapse>

View File

@@ -11,8 +11,8 @@ import type {
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs, { Dayjs } from 'dayjs';
import { isEmpty } from 'lodash-es';
import dayjs from 'dayjs';
import { isEmpty, isEqual } from 'lodash-es';
import APIError from 'types/api/error';
type DateTimeString = string | null | undefined;
@@ -38,20 +38,14 @@ export const getDuration = (
return `${hours} hours`;
};
export const formatDateTime = (
dateTimeString?: string | Dayjs | null,
timezone?: string,
): string => {
export const formatDateTime = (dateTimeString?: string | null): string => {
if (!dateTimeString) {
return 'N/A';
}
let dt = dayjs(dateTimeString);
if (timezone) {
dt = dt.tz(timezone);
}
return dt.format(DATE_TIME_FORMATS.MONTH_DATETIME);
return dayjs(dateTimeString.slice(0, 19)).format(
DATE_TIME_FORMATS.MONTH_DATETIME,
);
};
export const getAlertOptionsFromIds = (
@@ -67,7 +61,6 @@ export const getAlertOptionsFromIds = (
export const recurrenceInfo = (
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
): string => {
if (!recurrence) {
return 'No';
@@ -76,10 +69,10 @@ export const recurrenceInfo = (
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
? formatDateTime(dayjs(startTime).toISOString())
: '';
const formattedEndTime = endTime
? `to ${formatDateTime(endTime, timezone)}`
? `to ${formatDateTime(dayjs(endTime).toISOString())}`
: '';
const weeklyRepeatString = repeatOn ? `on ${repeatOn.join(', ')}` : '';
const durationString = duration ? `- Duration: ${duration}` : '';
@@ -87,20 +80,22 @@ export const recurrenceInfo = (
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
};
export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDTO> =
{
name: '',
description: '',
schedule: {
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: undefined,
},
alertIds: [],
createdAt: undefined,
createdBy: undefined,
};
export const defautlInitialValues: Partial<
AlertmanagertypesPlannedMaintenanceDTO & { editMode: boolean }
> = {
name: '',
description: '',
schedule: {
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: undefined,
},
alertIds: [],
createdAt: undefined,
createdBy: undefined,
editMode: false,
};
type DeleteDowntimeScheduleProps = {
deleteDowntimeScheduleAsync: UseMutateAsyncFunction<
@@ -215,6 +210,75 @@ export const recurrenceOptionWithSubmenu: Option[] = [
recurrenceOptions.monthly,
];
export const getRecurrenceOptionFromValue = (
value?: string | Option | null,
): Option | null | undefined => {
if (!value) {
return null;
}
if (typeof value === 'string') {
return Object.values(recurrenceOptions).find(
(option) => option.value === value,
);
}
return value;
};
export const getEndTime = ({
kind,
schedule,
}: Partial<
AlertmanagertypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>): string | dayjs.Dayjs => {
if (kind === 'fixed') {
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
}
return schedule?.recurrence?.endTime
? dayjs(schedule.recurrence.endTime).toISOString()
: '';
};
export const isScheduleRecurring = (
schedule?: AlertmanagertypesPlannedMaintenanceDTO['schedule'] | null,
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
const sign = offsetMinutes >= 0 ? '+' : '-';
const absOffset = Math.abs(offsetMinutes);
const hours = String(Math.floor(absOffset / 60)).padStart(2, '0');
const minutes = String(absOffset % 60).padStart(2, '0');
return `${sign}${hours}:${minutes}`;
}
export function formatWithTimezone(
dateValue?: string | dayjs.Dayjs,
timezone?: string,
): string {
const parsedDate =
typeof dateValue === 'string' ? dateValue : dateValue?.format();
// Get the target timezone offset
const targetOffset = convertUtcOffsetToTimezoneOffset(
dayjs(dateValue).tz(timezone).utcOffset(),
);
return `${parsedDate?.substring(0, 19)}${targetOffset}`;
}
export function handleTimeConversion(
dateValue: string | dayjs.Dayjs,
timezoneInit?: string,
timezone?: string,
shouldKeepLocalTime?: boolean,
): string {
const timezoneChanged = !isEqual(timezoneInit, timezone);
const initialTime = dayjs(dateValue).tz(timezoneInit);
const formattedTime = formatWithTimezone(initialTime, timezone);
return timezoneChanged
? formattedTime
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
}

View File

@@ -27,10 +27,10 @@ const MOCK_DATE_3 = '2024-01-03';
const MOCK_DOWNTIME_1 = createMockDowntime({
id: '1',
name: MOCK_DOWNTIME_1_NAME,
createdAt: MOCK_DATE_1,
updatedAt: MOCK_DATE_1,
createdAt: new Date(MOCK_DATE_1),
updatedAt: new Date(MOCK_DATE_1),
schedule: buildSchedule({
startTime: MOCK_DATE_1,
startTime: new Date(MOCK_DATE_1),
timezone: 'UTC',
}),
alertIds: [],
@@ -39,10 +39,10 @@ const MOCK_DOWNTIME_1 = createMockDowntime({
const MOCK_DOWNTIME_2 = createMockDowntime({
id: '2',
name: MOCK_DOWNTIME_2_NAME,
createdAt: MOCK_DATE_2,
updatedAt: MOCK_DATE_2,
createdAt: new Date(MOCK_DATE_2),
updatedAt: new Date(MOCK_DATE_2),
schedule: buildSchedule({
startTime: MOCK_DATE_2,
startTime: new Date(MOCK_DATE_2),
timezone: 'UTC',
}),
alertIds: [],
@@ -51,10 +51,10 @@ const MOCK_DOWNTIME_2 = createMockDowntime({
const MOCK_DOWNTIME_3 = createMockDowntime({
id: '3',
name: MOCK_DOWNTIME_3_NAME,
createdAt: MOCK_DATE_3,
updatedAt: MOCK_DATE_3,
createdAt: new Date(MOCK_DATE_3),
updatedAt: new Date(MOCK_DATE_3),
schedule: buildSchedule({
startTime: MOCK_DATE_3,
startTime: new Date(MOCK_DATE_3),
timezone: 'UTC',
}),
alertIds: [],

View File

@@ -24,7 +24,7 @@ export const createMockDowntime = (
description: overrides.description ?? '',
schedule: buildSchedule({
timezone: 'UTC',
startTime: '2024-01-01',
startTime: new Date('2024-01-01'),
...overrides.schedule,
}),
alertIds: overrides.alertIds ?? [],

View File

@@ -16,31 +16,35 @@ enum SpanScope {
ENTRYPOINT_SPANS = 'entrypoint_spans',
}
interface SpanFilterConfig {
key: string;
type: string;
}
interface SpanScopeSelectorProps {
onChange?: (value: TagFilter) => void;
query?: IBuilderQuery;
skipQueryBuilderRedirect?: boolean;
}
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
[SpanScope.ALL_SPANS]: null,
[SpanScope.ROOT_SPANS]: 'isRoot',
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
[SpanScope.ROOT_SPANS]: {
key: 'isRoot',
type: 'spanSearchScope',
},
[SpanScope.ENTRYPOINT_SPANS]: {
key: 'isEntryPoint',
type: 'spanSearchScope',
},
};
const SCOPE_FILTER_KEYS = Object.values(SPAN_FILTER_KEY).filter(
(key): key is string => key !== null,
);
const isScopeFilter = (filter: TagFilterItem, key: string): boolean =>
filter.key?.key === key && String(filter.value) === 'true';
const createFilterItem = (key: string): TagFilterItem => ({
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
id: uuid().slice(0, 8),
key: {
key,
key: config.key,
dataType: undefined,
type: '',
type: config?.type,
},
op: '=',
value: 'true',
@@ -66,7 +70,12 @@ function SpanScopeSelector({
filters: TagFilterItem[] = [],
): SpanScope => {
const hasFilter = (key: string): boolean =>
filters?.some((filter) => isScopeFilter(filter, key));
filters?.some(
(filter) =>
filter.key?.type === 'spanSearchScope' &&
filter.key.key === key &&
filter.value === 'true',
);
if (hasFilter('isRoot')) {
return SpanScope.ROOT_SPANS;
@@ -104,21 +113,28 @@ function SpanScopeSelector({
const nonScopeFilters = currentFilters.filter(
(filter) =>
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
!(
filter.key?.type === 'spanSearchScope' &&
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
),
);
const scopeKey = SPAN_FILTER_KEY[newScope];
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
const config = SPAN_FILTER_CONFIG[newScope];
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
return [...nonScopeFilters, ...newScopeFilter];
};
const keysToRemove = Object.values(SPAN_FILTER_CONFIG)
.map((config) => config?.key)
.filter((key): key is string => typeof key === 'string');
newQuery.builder.queryData = newQuery.builder.queryData.map((item) => ({
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
SCOPE_FILTER_KEYS,
keysToRemove,
),
},
filters: {

Some files were not shown because too many files have changed in this diff Show More