Compare commits

..

23 Commits

Author SHA1 Message Date
SagarRajput-7
106a17c306 Merge branch 'no-auth-fe' into no-auth-mode-tests 2026-05-20 03:24:20 +05:30
SagarRajput-7
49f4d4d620 feat(no-auth): added no auth centric test and test patterns 2026-05-20 03:18:44 +05:30
SagarRajput-7
480239fac4 Merge branch 'main' into no-auth-fe 2026-05-20 03:17:42 +05:30
SagarRajput-7
abe03b69b6 feat(no-auth): added doc link under learn more text 2026-05-20 03:15:54 +05:30
SagarRajput-7
938accbe80 feat(no-auth): changed banner text and code refactor 2026-05-20 03:02:02 +05:30
SagarRajput-7
bc6521bf25 feat(no-auth): added noauth guard at more places and refactor 2026-05-20 01:50:46 +05:30
Vishal Sharma
eab8d45611 chore(ai-assistant): add product analytics events (#11331)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
* chore(ai-assistant): add product analytics events

Wire 14 frontend product-analytics events for the AI Assistant feature
so we can measure the open→send funnel, action conversion, voice usage,
and feature adoption. All events go through the existing `logEvent`
helper, with a shared `useAIAssistantAnalyticsContext` hook providing
`{ threadId, page, mode }`.

Events shipped:
- AI Assistant: Opened (source: icon | shortcut | deeplink)
- AI Assistant: New chat clicked
- AI Assistant: Message sent
- AI Assistant: Suggested prompt clicked
- AI Assistant: Cancel clicked
- AI Assistant: Regenerate clicked
- AI Assistant: Message copied
- AI Assistant: Feedback submitted
- AI Assistant: Resource opened
- AI Assistant: Doc opened
- AI Assistant: Apply filter clicked
- AI Assistant: Thread opened from history
- AI Assistant: Voice input used
- AI Assistant: Voice input failed

Additional changes:
- Suppress duplicate `Opened` fires when expanding drawer/modal to the
  full-screen page (markExpandFromInApp / consumeExpandFromInApp flag).
- Toast + analytics + sessionStorage-persisted hide for voice failures
  on Chromium derivatives that lack the Google Speech API key.
- Browser info (name, version, platform, userAgent) attached to voice
  events to triage browser-specific failures.

Skipped per scope: executionId on Cancel clicked, toolName on action
events, turnCategory on Feedback submitted, promptCategory on suggested
prompts — would require store/DTO changes beyond instrumentation.

* fix(ai-assistant): address review feedback on analytics events

- Replace markExpand module flag with router state so the Opened event
  stays correct across StrictMode double-mounts and aborted navigations.
- Guard the voice push-to-talk shortcut on voiceUnavailable so it can't
  bypass the persisted hide-after-failure flag.
- Fire SuggestedPromptClicked (category: follow_up) alongside MessageSent
  on server-emitted follow_up chips so click-through can be measured.
- Normalize the page/currentPage attribute to its ROUTES template via
  matchPath, bounding cardinality and avoiding customer IDs in analytics.
- Pick browsers from userAgentData via a derivative-first priority list,
  fall through to UA sniffing for generic Chromium hits, and probe
  navigator.brave to distinguish Brave from plain Chrome.

* refactor(ai-assistant): simplify analytics scaffolding

- Trim getBrowserInfo to UA-sniffing + Brave probe; drop the brand
  priority list, isGenericBrand gate, and userAgent/platform fields
  the backend can derive from request headers anyway.
- Inline the router-state shape at its three call sites instead of
  exporting a named interface for { fromInApp?: boolean }.
- Tighten comments across the module — keep the non-obvious "why"
  bits, drop the restated ones.

* fix(ai-assistant): apply PR review feedback on analytics events

- HeaderRightSection: rename Opened source 'icon' -> 'header' to reflect
  where the icon lives, not how it looks.
- AIAssistantPage: normalize pathname on NewChatClicked so the
  conversation id doesn't leak into the page attribute.
- ConversationView: invert the streaming useEffect to an early bailout
  when not streaming for readability.
- ActionsSection: extract resource-type case strings into a ResourceType
  constants object shared by targetModuleForResource and resourceRoute.
- VirtualizedMessages + ActionsSection: replace 'follow_up' / 'empty_state'
  magic strings with a SuggestedPromptCategory constants in events.ts.
2026-05-19 16:33:30 +00:00
Jatinderjit Singh
c73fdd1f81 fix(planned-downtime): timezone handling (#11318)
* fix(planned-downtime): timezone handling

Don't convert the start/end times to UTC for the request. Serialize
as per the input timezone instead.

* Fix date/string conversion issues

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-19 15:55:11 +00:00
Aditya Singh
6094a5eee2 fix: remove spanScopeSelector key to sync scope filters (#11355) 2026-05-19 15:39:23 +00:00
swapnil-signoz
93407d3bcc feat(sqlmigration): add integration_dashboard table (#11343)
* feat(sqlmigration): add integration_dashboards table (migration 079)

Adds the `integration_dashboards` relations table that stores the
integration-specific identity for dashboards provisioned from cloud
or builtin integrations. Columns: id, org_id, dashboard_id, provider,
slug, created_at, updated_at. Includes a unique index on dashboard_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: adding comment for fk

* refactor: renaming table name

* chore: file rename

* refactor: removing org_id column and adding fk relation

* refactor: rename integration dashboards factory to singular

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:24:27 +00:00
Abhi kumar
5bd4cabbca fix: added fix for widget warning/save modal states (#11356)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
2026-05-19 13:31:14 +00:00
SagarRajput-7
cf1cf2695b Merge branch 'main' into no-auth-fe 2026-05-19 18:55:47 +05:30
SagarRajput-7
6b25d82e77 feat(no-auth): refactor and feedback fix 2026-05-19 18:54:18 +05:30
Aditya Singh
f9e21cecd8 feat: field selector migrated to telemetry field key (#11360)
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector
2026-05-19 13:14:44 +00:00
SagarRajput-7
e7ddb965ad feat(no-auth): added noauth guard at more places and added tests 2026-05-19 18:28:55 +05:30
SagarRajput-7
819fce9cea feat(no-auth): fixes and refactor after rebase 2026-05-19 17:01:54 +05:30
SagarRajput-7
de27746256 feat(no-auth): added more authguard 2026-05-19 17:01:01 +05:30
SagarRajput-7
f6b7b90419 feat(no-auth): added no-auth announcement banner and added authguard on sa page 2026-05-19 17:01:01 +05:30
SagarRajput-7
d115d54024 feat(no-auth): replace localstorage approach with module-level singleton 2026-05-19 17:01:01 +05:30
SagarRajput-7
978788b544 feat(no-auth): replace hide pattern with disable+tooltip via NoAuthGuard 2026-05-19 17:00:58 +05:30
SagarRajput-7
88fedb5081 feat(no-auth): setup interceptor and ui hiding for no auth mode 2026-05-19 17:00:04 +05:30
SagarRajput-7
54a4b5efc1 feat(no-auth): wire preflight global-config check and gate AppRoutes render & cleanAuthStorage util 2026-05-19 17:00:04 +05:30
Ashwin Bhatkal
4b98b0bb27 fix(dashboard): component UX updates in widget header and settings panel (#11357)
Bundles four small UX fixes — three regressions from the typography
(#11199) and icons (#11222) migrations, plus the DashboardDescription
fallout from #11352:

- Widget panel title truncates to "Title..." even when the panel has
  plenty of horizontal space. The title container had no `flex: 1` /
  `min-width: 0`, so it collapsed to content width and the 80% cap
  triggered early truncation. Make the title row a real flex item.
- Variable editor "Default Value" label and helper text run together
  on one line. `Typography` from `@signozhq/ui` defaults to
  `display: inline`, so the helper text sat next to the label. Force
  block layout in the default-value-section.
- Cross-Panel Sync info icon was the outline `Info`, inconsistent with
  the `SolidInfoCircle` used everywhere else (widget header, threshold,
  status message). Swap to the standard icon at size "md".
- After #11352, DeleteButton renders as an antd `<Button>`, but the
  DashboardDescription action menu still targeted `.ant-typography`
  for the delete entry, so it picked up the list-page module's 8px /
  12px styling and went out of sync with its peers. Consolidate the
  three near-duplicate `section-1` / `section-2` / `delete-dashboard`
  blocks into a single `section .ant-btn` rule, with section dividers
  and the danger color as the only per-section overrides.
2026-05-19 10:49:46 +00:00
151 changed files with 3954 additions and 3562 deletions

View File

@@ -8,14 +8,6 @@ packages:
filename: "alertmanager.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/types/alertmanagertypes:
interfaces:
MaintenanceStore:
config:
dir: '{{.InterfaceDir}}/alertmanagertypestest'
filename: "maintenance.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/tokenizer:
config:
all: true

View File

@@ -96,55 +96,6 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
- recurring
type: string
AlertmanagertypesMaintenanceStatus:
enum:
- active
- upcoming
- expired
type: string
AlertmanagertypesPlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceKind'
labelExpression:
type: string
name:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- name
- schedule
- status
- kind
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
@@ -261,25 +212,6 @@ components:
required:
- name
type: object
AlertmanagertypesPostablePlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
description:
type: string
labelExpression:
type: string
name:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
required:
- name
- schedule
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -305,60 +237,6 @@ components:
- channels
- name
type: object
AlertmanagertypesRecurrence:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
nullable: true
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
AlertmanagertypesRepeatOn:
enum:
- sunday
- monday
- tuesday
- wednesday
- thursday
- friday
- saturday
type: string
AlertmanagertypesRepeatType:
enum:
- daily
- weekly
- monthly
type: string
AlertmanagertypesSchedule:
properties:
endTime:
format: date-time
type: string
recurrence:
$ref: '#/components/schemas/AlertmanagertypesRecurrence'
startTime:
format: date-time
type: string
timezone:
type: string
required:
- timezone
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -5259,6 +5137,17 @@ components:
message:
type: string
type: object
RuletypesMaintenanceKind:
enum:
- fixed
- recurring
type: string
RuletypesMaintenanceStatus:
enum:
- active
- upcoming
- expired
type: string
RuletypesMatchType:
enum:
- at_least_once
@@ -5286,6 +5175,59 @@ components:
- table
- graph
type: string
RuletypesPlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/RuletypesMaintenanceKind'
name:
type: string
schedule:
$ref: '#/components/schemas/RuletypesSchedule'
status:
$ref: '#/components/schemas/RuletypesMaintenanceStatus'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- name
- schedule
- status
- kind
type: object
RuletypesPostablePlannedMaintenance:
properties:
alertIds:
items:
type: string
nullable: true
type: array
description:
type: string
name:
type: string
schedule:
$ref: '#/components/schemas/RuletypesSchedule'
required:
- name
- schedule
type: object
RuletypesPostableRule:
properties:
alert:
@@ -5338,6 +5280,29 @@ components:
- clickhouse_sql
- promql
type: string
RuletypesRecurrence:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/RuletypesRepeatOn'
nullable: true
type: array
repeatType:
$ref: '#/components/schemas/RuletypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
RuletypesRenotify:
properties:
alertStates:
@@ -5349,6 +5314,22 @@ components:
interval:
type: string
type: object
RuletypesRepeatOn:
enum:
- sunday
- monday
- tuesday
- wednesday
- thursday
- friday
- saturday
type: string
RuletypesRepeatType:
enum:
- daily
- weekly
- monthly
type: string
RuletypesRollingWindow:
properties:
evalWindow:
@@ -5468,6 +5449,21 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesSchedule:
properties:
endTime:
format: date-time
type: string
recurrence:
$ref: '#/components/schemas/RuletypesRecurrence'
startTime:
format: date-time
type: string
timezone:
type: string
required:
- timezone
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -8028,7 +8024,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
type: array
status:
type: string
@@ -8071,7 +8067,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostablePlannedMaintenance'
$ref: '#/components/schemas/RuletypesPostablePlannedMaintenance'
responses:
"201":
content:
@@ -8079,7 +8075,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
status:
type: string
required:
@@ -8182,7 +8178,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesPlannedMaintenance'
$ref: '#/components/schemas/RuletypesPlannedMaintenance'
status:
type: string
required:
@@ -8236,7 +8232,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostablePlannedMaintenance'
$ref: '#/components/schemas/RuletypesPostablePlannedMaintenance'
responses:
"204":
description: No Content

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -48,7 +49,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +73,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -95,7 +96,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -209,9 +210,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// newTask returns an appropriate group for the rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

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

View File

@@ -166,6 +166,8 @@ function createMockAppContext(
userPreferences: [],
hostsData: null,
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -59,6 +59,7 @@ function App(): JSX.Element {
isLoggedIn: isLoggedInState,
featureFlags,
org,
isPreflightLoading,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
@@ -386,6 +387,10 @@ function App(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloudUser, isEnterpriseSelfHostedUser]);
if (isPreflightLoading) {
return <Spinner tip="Loading..." />;
}
// if the user is in logged in state
if (isLoggedInState) {
// if the setup calls are loading then return a spinner

View File

@@ -0,0 +1,72 @@
import axios from 'axios';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import { interceptorRejected } from '../index';
jest.mock('utils/noAuthMode', () => ({
getIsNoAuthMode: jest.fn(),
}));
jest.mock('api/v2/sessions/rotate/post', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('AppRoutes/utils', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../utils', () => ({
Logout: jest.fn(),
}));
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const post = require('api/v2/sessions/rotate/post').default;
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const { Logout } = require('../utils');
describe('interceptorRejected — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
});
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).not.toHaveBeenCalled();
expect(Logout).not.toHaveBeenCalled();
});
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
(post as jest.Mock).mockResolvedValue({
data: { accessToken: 'a', refreshToken: 'b' },
});
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).toHaveBeenCalled();
});
});

View File

@@ -18,7 +18,6 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostablePlannedMaintenanceDTO,
CreateDowntimeSchedule201,
DeleteDowntimeScheduleByIDPathParameters,
GetDowntimeScheduleByID200,
@@ -26,6 +25,7 @@ import type {
ListDowntimeSchedules200,
ListDowntimeSchedulesParams,
RenderErrorResponseDTO,
RuletypesPostablePlannedMaintenanceDTO,
UpdateDowntimeScheduleByIDPathParameters,
} from '../sigNoz.schemas';
@@ -135,14 +135,14 @@ export const invalidateListDowntimeSchedules = async (
* @summary Create downtime schedule
*/
export const createDowntimeSchedule = (
alertmanagertypesPostablePlannedMaintenanceDTO?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>,
ruletypesPostablePlannedMaintenanceDTO?: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDowntimeSchedule201>({
url: `/api/v1/downtime_schedules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostablePlannedMaintenanceDTO,
data: ruletypesPostablePlannedMaintenanceDTO,
signal,
});
};
@@ -154,13 +154,13 @@ export const getCreateDowntimeScheduleMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
> => {
const mutationKey = ['createDowntimeSchedule'];
@@ -174,7 +174,7 @@ export const getCreateDowntimeScheduleMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> }
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -188,7 +188,7 @@ export type CreateDowntimeScheduleMutationResult = NonNullable<
Awaited<ReturnType<typeof createDowntimeSchedule>>
>;
export type CreateDowntimeScheduleMutationBody =
| BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>
| BodyType<RuletypesPostablePlannedMaintenanceDTO>
| undefined;
export type CreateDowntimeScheduleMutationError =
ErrorType<RenderErrorResponseDTO>;
@@ -203,13 +203,13 @@ export const useCreateDowntimeSchedule = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO> },
{ data?: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
> => {
return useMutation(getCreateDowntimeScheduleMutationOptions(options));
@@ -403,14 +403,14 @@ export const invalidateGetDowntimeScheduleByID = async (
*/
export const updateDowntimeScheduleByID = (
{ id }: UpdateDowntimeScheduleByIDPathParameters,
alertmanagertypesPostablePlannedMaintenanceDTO?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>,
ruletypesPostablePlannedMaintenanceDTO?: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/downtime_schedules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostablePlannedMaintenanceDTO,
data: ruletypesPostablePlannedMaintenanceDTO,
signal,
});
};
@@ -424,7 +424,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
@@ -433,7 +433,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {
@@ -450,7 +450,7 @@ export const getUpdateDowntimeScheduleByIDMutationOptions = <
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -465,7 +465,7 @@ export type UpdateDowntimeScheduleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>
>;
export type UpdateDowntimeScheduleByIDMutationBody =
| BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>
| BodyType<RuletypesPostablePlannedMaintenanceDTO>
| undefined;
export type UpdateDowntimeScheduleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
@@ -482,7 +482,7 @@ export const useUpdateDowntimeScheduleByID = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
@@ -491,7 +491,7 @@ export const useUpdateDowntimeScheduleByID = <
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data?: BodyType<AlertmanagertypesPostablePlannedMaintenanceDTO>;
data?: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {

View File

@@ -9,7 +9,7 @@ export interface AlertmanagertypesChannelDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -34,7 +34,7 @@ export interface AlertmanagertypesChannelDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ModelLabelSetDTO {
@@ -62,7 +62,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
endsAt?: Date;
endsAt?: string;
/**
* @type string
*/
@@ -80,7 +80,7 @@ export interface AlertmanagertypesDeprecatedGettableAlertDTO {
* @type string
* @format date-time
*/
startsAt?: Date;
startsAt?: string;
status?: TypesAlertStatusDTO;
}
@@ -97,7 +97,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt: string;
/**
* @type string,null
*/
@@ -127,120 +127,13 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string,null
*/
updatedBy?: string | null;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum AlertmanagertypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export enum AlertmanagertypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum AlertmanagertypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface AlertmanagertypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: Date | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: Date;
}
export interface AlertmanagertypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: Date;
recurrence?: AlertmanagertypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: Date;
/**
* @type string
*/
timezone: string;
}
export interface AlertmanagertypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesMaintenanceKindDTO;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
}
export interface ConfigAuthorizationDTO {
/**
* @type string
@@ -1704,26 +1597,6 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
wechat_configs?: ConfigWechatConfigDTO[];
};
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
/**
* @type array,null
@@ -1961,7 +1834,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -1978,7 +1851,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
@@ -2136,7 +2009,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2161,7 +2034,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
@@ -2189,7 +2062,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt: string;
/**
* @type string
*/
@@ -2203,7 +2076,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string
*/
@@ -2215,7 +2088,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2244,7 +2117,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type array,null
*/
@@ -2411,7 +2284,7 @@ export interface CloudintegrationtypesAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2432,12 +2305,12 @@ export interface CloudintegrationtypesAccountDTO {
* @type string,null
* @format date-time
*/
removedAt: Date | null;
removedAt: string | null;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
@@ -2568,7 +2441,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2578,7 +2451,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
};
/**
@@ -2772,12 +2645,12 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @type string,null
* @format date-time
*/
removed_at: Date | null;
removed_at: string | null;
/**
* @type string,null
* @format date-time
*/
removedAt: Date | null;
removedAt: string | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -3012,7 +2885,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -3034,7 +2907,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -3216,7 +3089,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
created_at?: Date;
created_at?: string;
/**
* @type string
*/
@@ -3238,7 +3111,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
}
export interface GatewaytypesIngestionKeyDTO {
@@ -3246,12 +3119,12 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
created_at?: Date;
created_at?: string;
/**
* @type string
* @format date-time
*/
expires_at?: Date;
expires_at?: string;
/**
* @type string
*/
@@ -3272,7 +3145,7 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
/**
* @type string
*/
@@ -3296,7 +3169,7 @@ export interface GatewaytypesPostableIngestionKeyDTO {
* @type string
* @format date-time
*/
expires_at?: Date;
expires_at?: string;
/**
* @type string
*/
@@ -4566,7 +4439,7 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -4605,13 +4478,13 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string,null
* @format date-time
*/
syncedAt?: Date | null;
syncedAt?: string | null;
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -5837,7 +5710,7 @@ export interface Querybuildertypesv5RawRowDTO {
* @type string
* @format date-time
*/
timestamp?: Date;
timestamp?: string;
}
export interface Querybuildertypesv5RawDataDTO {
@@ -6243,6 +6116,15 @@ export interface RuletypesGettableTestRuleDTO {
message?: string;
}
export enum RuletypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum RuletypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export interface RuletypesRenotifyDTO {
/**
* @type array
@@ -6274,6 +6156,116 @@ export interface RuletypesNotificationSettingsDTO {
usePolicy?: boolean;
}
export enum RuletypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum RuletypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface RuletypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: RuletypesRepeatOnDTO[] | null;
repeatType: RuletypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface RuletypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: string;
recurrence?: RuletypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: string;
/**
* @type string
*/
timezone: string;
}
export interface RuletypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: RuletypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
status: RuletypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface RuletypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
}
export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
@@ -6414,7 +6406,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6463,7 +6455,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6482,7 +6474,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type integer
* @minimum 0
@@ -6496,7 +6488,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
lastObservedAt: Date;
lastObservedAt: string;
/**
* @type string
*/
@@ -6509,7 +6501,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
@@ -6554,7 +6546,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6579,7 +6571,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountRoleDTO {
@@ -6587,7 +6579,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6605,7 +6597,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
@@ -6613,7 +6605,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6642,7 +6634,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
@@ -6684,7 +6676,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6709,7 +6701,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6778,7 +6770,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6804,7 +6796,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -7171,7 +7163,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7204,7 +7196,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesIdentifiableDTO {
@@ -7219,7 +7211,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7252,7 +7244,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesOrganizationDTO {
@@ -7264,7 +7256,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7286,7 +7278,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesPostableInviteDTO {
@@ -7353,7 +7345,7 @@ export interface TypesResetPasswordTokenDTO {
* @type string
* @format date-time
*/
expiresAt?: Date;
expiresAt?: string;
/**
* @type string
*/
@@ -7380,7 +7372,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7409,7 +7401,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ZeustypesHostDTO {
@@ -7801,7 +7793,7 @@ export type ListDowntimeSchedules200 = {
/**
* @type array
*/
data: AlertmanagertypesPlannedMaintenanceDTO[];
data: RuletypesPlannedMaintenanceDTO[];
/**
* @type string
*/
@@ -7809,7 +7801,7 @@ export type ListDowntimeSchedules200 = {
};
export type CreateDowntimeSchedule201 = {
data: AlertmanagertypesPlannedMaintenanceDTO;
data: RuletypesPlannedMaintenanceDTO;
/**
* @type string
*/
@@ -7823,7 +7815,7 @@ export type GetDowntimeScheduleByIDPathParameters = {
id: string;
};
export type GetDowntimeScheduleByID200 = {
data: AlertmanagertypesPlannedMaintenanceDTO;
data: RuletypesPlannedMaintenanceDTO;
/**
* @type string
*/

View File

@@ -13,6 +13,7 @@ import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
import { Logout } from './utils';
@@ -108,7 +109,10 @@ export const interceptorRejected = async (
if (axios.isAxiosError(value) && value.response) {
const { response } = value;
const isNoAuthMode = getIsNoAuthMode();
if (
!isNoAuthMode &&
response.status === 401 &&
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
response.config.url !== '/sessions/rotate' &&
@@ -140,16 +144,20 @@ export const interceptorRejected = async (
return await Promise.resolve(reResponse);
} catch (error) {
if ((error as AxiosError)?.response?.status === 401) {
Logout();
void Logout();
}
}
} catch (error) {
Logout();
void Logout();
}
}
if (response.status === 401 && response.config.url === '/sessions/rotate') {
Logout();
if (
!isNoAuthMode &&
response.status === 401 &&
response.config.url === '/sessions/rotate'
) {
void Logout();
}
}
return await Promise.reject(value);

View File

@@ -19,6 +19,7 @@ import {
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
@@ -59,7 +60,7 @@ function getDeleteTooltip(
function getInviteButtonLabel(
isLoading: boolean,
existingToken: { expiresAt?: Date } | undefined,
existingToken: { expiresAt?: string } | undefined,
isExpired: boolean,
notFound: boolean,
): string {
@@ -613,39 +614,43 @@ function EditMemberDrawer({
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<NoAuthGuard testId="no-auth-delete-member">
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</NoAuthGuard>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
<NoAuthGuard testId="no-auth-generate-reset-link">
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</NoAuthGuard>
</span>
</Tooltip>
</div>
@@ -656,15 +661,17 @@ function EditMemberDrawer({
Cancel
</Button>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
<NoAuthGuard testId="no-auth-save-member">
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</NoAuthGuard>
</div>
</>
)}

View File

@@ -0,0 +1,113 @@
import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useGetRolesByUserID,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { MemberStatus } from 'container/MembersSettings/utils';
import { managedRoles } from 'mocks-server/__mockdata__/roles';
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import EditMemberDrawer from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
],
}));
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
function setupMocks(): void {
(useGetUser as jest.Mock).mockReturnValue({
data: {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [
{ id: 'ur-1', roleId: managedRoles[0].id, role: managedRoles[0] },
],
},
},
isLoading: false,
refetch: jest.fn(),
});
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
mutate: jest.fn(),
isLoading: false,
});
(useGetResetPasswordToken as jest.Mock).mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
(useCreateResetPasswordToken as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
}
describe('EditMemberDrawer — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
setupMocks();
});
it('renders no-auth guard wrappers for all member mutation buttons', () => {
renderWithNoAuth(
<EditMemberDrawer
member={activeMember}
open
onClose={jest.fn()}
onComplete={jest.fn()}
/>,
);
expect(screen.getByTestId('no-auth-delete-member')).toBeInTheDocument();
expect(screen.getByTestId('no-auth-generate-reset-link')).toBeInTheDocument();
expect(screen.getByTestId('no-auth-save-member')).toBeInTheDocument();
});
});

View File

@@ -18,21 +18,22 @@ import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Typography } from '@signozhq/ui/typography';
import styles from './FieldsSettings.module.scss';
import styles from './FieldsSelector.module.scss';
function SortableField({
field,
onRemove,
allowDrag,
}: {
field: BaseAutocompleteData;
onRemove: (field: BaseAutocompleteData) => void;
field: TelemetryFieldKey;
onRemove: (field: TelemetryFieldKey) => void;
allowDrag: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.key });
useSortable({ id: field.name });
const style = {
transform: CSS.Transform.toString(transform),
@@ -50,7 +51,7 @@ function SortableField({
>
<div {...attributes} {...listeners} className={styles.dragHandle}>
{allowDrag && <GripVertical size={14} />}
<span className={styles.fieldKey}>{field.key}</span>
<span className={styles.fieldKey}>{field.name}</span>
</div>
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
@@ -67,41 +68,52 @@ function SortableField({
interface AddedFieldsProps {
inputValue: string;
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
maxFields?: number;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
maxFields,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
const oldIndex = fields.findIndex((f) => f.name === active.id);
const newIndex = fields.findIndex((f) => f.name === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
fields.filter((f) =>
f.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[fields, inputValue],
);
const handleRemove = (field: BaseAutocompleteData): void => {
onFieldsChange(fields.filter((f) => f.key !== field.key));
const handleRemove = (field: TelemetryFieldKey): void => {
onFieldsChange(fields.filter((f) => f.name !== field.name));
};
const allowDrag = inputValue.length === 0;
return (
<div className={cx(styles.section, styles.sectionAdded)}>
<div className={styles.sectionHeader}>ADDED FIELDS</div>
<div className={styles.sectionHeader}>
<span>ADDED FIELDS</span>
{maxFields !== undefined && (
<Typography.Text size="sm" weight="medium" color="muted">
Max Allowed: {maxFields}
</Typography.Text>
)}
</div>
<div className={styles.addedList}>
<OverlayScrollbar>
<DndContext
@@ -113,13 +125,13 @@ function AddedFields({
<div className={styles.noValues}>No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.key)}
items={fields.map((f) => f.name)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.key}
key={field.name}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}

View File

@@ -56,12 +56,14 @@
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
@@ -89,13 +91,6 @@
font-size: 12px;
}
.limitHint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
.fieldItem {
display: flex;
align-items: center;

View File

@@ -0,0 +1,176 @@
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

@@ -4,51 +4,58 @@ import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
FieldDataType,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import styles from './FieldsSettings.module.scss';
import styles from './FieldsSelector.module.scss';
interface OtherFieldsProps {
dataSource: DataSource;
signal: DataSource;
debouncedInputValue: string;
addedFields: BaseAutocompleteData[];
onAdd: (field: BaseAutocompleteData) => void;
addedFields: TelemetryFieldKey[];
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
signal,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
const { data, isFetching } = useGetQueryKeySuggestions(
{
signal,
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
signal,
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedKeys = new Set(addedFields.map((f) => f.key));
return attributes.filter((attr) => !addedKeys.has(attr.key));
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
const addedNames = new Set(addedFields.map((f) => f.name));
return suggestions
.filter((attr) => !addedNames.has(attr.name))
.map((attr) => ({
...attr,
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
}));
}, [data, addedFields]);
if (isFetching) {
@@ -76,10 +83,10 @@ function OtherFields({
) : (
otherFields.map((attr) => (
<div
key={attr.key}
key={attr.name}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<span className={styles.fieldKey}>{attr.key}</span>
<span className={styles.fieldKey}>{attr.name}</span>
{!isAtLimit && (
<Button
className={cx(styles.addBtn, 'periscope-btn')}
@@ -94,7 +101,6 @@ function OtherFields({
</div>
))
)}
{isAtLimit && <div className={styles.limitHint}>Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>

View File

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

View File

@@ -5,6 +5,8 @@ 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,
@@ -50,6 +52,14 @@ 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,
@@ -101,7 +111,7 @@ function HeaderRightSection({
<Button
variant="solid"
color="secondary"
onClick={openAIAssistant}
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1

View File

@@ -0,0 +1,13 @@
.banner {
height: var(--spacing-20);
a {
color: var(--callout-warning-title);
text-decoration: underline;
&:hover {
color: var(--callout-warning-title);
opacity: 0.8;
}
}
}

View File

@@ -0,0 +1,26 @@
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
import styles from './NoAuthBanner.module.scss';
export function NoAuthBanner(): JSX.Element {
return (
<PersistedAnnouncementBanner
type="warning"
storageKey="no-auth-banner-v1"
testId="no-auth-banner"
className={styles.banner}
>
No-auth mode: authentication is disabled and you are currently signed in as
an admin.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/configuration/no-auth-mode/"
target="_blank"
rel="noreferrer"
>
Learn more
</a>
</PersistedAnnouncementBanner>
);
}
export default NoAuthBanner;

View File

@@ -0,0 +1,24 @@
import { render, screen } from 'tests/test-utils';
import { NoAuthBanner } from '../NoAuthBanner';
describe('NoAuthBanner', () => {
it('renders the no-auth message', () => {
render(<NoAuthBanner />);
expect(
screen.getByText(/No-auth mode: authentication is disabled/i),
).toBeInTheDocument();
});
it('renders with the warning test id', () => {
render(<NoAuthBanner />);
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
});
it('renders a docs link that opens in a new tab', () => {
render(<NoAuthBanner />);
const link = screen.getByRole('link', { name: /learn more/i });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noreferrer');
});
});

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useAppContext } from 'providers/App/App';
export const DEFAULT_NO_AUTH_MESSAGE = 'Not available in no-auth mode';
interface NoAuthGuardProps {
children: React.ReactElement;
message?: string;
disabled?: boolean;
testId?: string;
}
export function NoAuthGuard({
children,
message = DEFAULT_NO_AUTH_MESSAGE,
disabled,
testId,
}: NoAuthGuardProps): JSX.Element {
const { isNoAuthMode } = useAppContext();
if (!isNoAuthMode) {
return disabled ? React.cloneElement(children, { disabled: true }) : children;
}
const disabledChild = React.cloneElement(children, {
disabled: true,
style: { ...(children.props.style ?? {}), pointerEvents: 'none' },
});
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span
data-no-auth-trigger
data-testid={testId}
style={{ display: 'inline-flex', cursor: 'not-allowed' }}
>
{disabledChild}
</span>
</TooltipTrigger>
<TooltipContent>{message}</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { render } from 'tests/test-utils';
import { NoAuthGuard } from '..';
describe('NoAuthGuard', () => {
it('renders children unchanged when isNoAuthMode is false', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
expect(getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('does not intercept onClick when isNoAuthMode is false', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<NoAuthGuard>
<button type="button" onClick={handleClick}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
getByRole('button', { name: 'Action' }).click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disables children when isNoAuthMode is true', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('renders a tooltip trigger wrapper when isNoAuthMode is true', () => {
const { container } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(
container.querySelector('span[data-no-auth-trigger]'),
).toBeInTheDocument();
});
it('blocks onClick when isNoAuthMode is true', () => {
const handleClick = jest.fn();
const { container } = render(
<NoAuthGuard>
<button type="button" onClick={handleClick}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
container
.querySelector('span[data-no-auth-trigger]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(handleClick).not.toHaveBeenCalled();
});
it('overrides existing disabled prop — no-auth always wins', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button" disabled={false}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('sets pointerEvents none on child when isNoAuthMode is true', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toHaveStyle({
pointerEvents: 'none',
});
});
});

View File

@@ -0,0 +1 @@
export { DEFAULT_NO_AUTH_MESSAGE, NoAuthGuard } from './NoAuthGuard';

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { NoAuthGuard } from 'components/NoAuthGuard';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -125,17 +126,19 @@ function KeyFormPhase({
]}
enabled={!!accountId}
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
<NoAuthGuard testId="no-auth-create-key">
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</NoAuthGuard>
</AuthZTooltip>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { NoAuthGuard } from 'components/NoAuthGuard';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
@@ -174,10 +175,12 @@ function EditKeyForm({
]}
enabled={!!accountId && !!keyItem?.id}
>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
<NoAuthGuard testId="no-auth-revoke-key">
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</NoAuthGuard>
</AuthZTooltip>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
@@ -188,17 +191,19 @@ function EditKeyForm({
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!accountId && !!keyItem?.id}
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
<NoAuthGuard testId="no-auth-save-key">
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</NoAuthGuard>
</AuthZTooltip>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table } from 'antd';
import { Skeleton, Table, Tooltip } from 'antd';
import { DEFAULT_NO_AUTH_MESSAGE, NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
@@ -33,6 +35,7 @@ interface KeysTabProps {
interface BuildColumnsParams {
isDisabled: boolean;
accountId: string;
isNoAuthMode: boolean;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -53,6 +56,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -110,28 +114,38 @@ function buildColumns({
onClick: (e): void => e.stopPropagation(),
style: { cursor: 'default' },
}),
render: (_, record): JSX.Element => (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(): void => {
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled
? 'Service account disabled'
: isNoAuthMode
? DEFAULT_NO_AUTH_MESSAGE
: 'Revoke Key';
return (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<X size={12} />
</Button>
</AuthZTooltip>
),
<Tooltip title={tooltipTitle}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled || isNoAuthMode}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
);
},
},
];
}
@@ -158,6 +172,7 @@ function KeysTab({
parseAsString.withDefault(''),
);
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
const { isNoAuthMode } = useAppContext();
const handleformatLastObservedAt = useCallback(
(lastObservedAt: Date | null | undefined): string =>
@@ -177,10 +192,17 @@ function KeysTab({
buildColumns({
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}),
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
[
isDisabled,
accountId,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
],
);
if (isLoading) {
@@ -210,16 +232,18 @@ function KeysTab({
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
<NoAuthGuard testId="no-auth-add-first-key">
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</NoAuthGuard>
</AuthZTooltip>
</div>
);

View File

@@ -2,6 +2,7 @@ import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { NoAuthGuard } from 'components/NoAuthGuard';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
@@ -52,15 +53,17 @@ export function RevokeKeyFooter({
]}
enabled={!!accountId && !!keyId}
>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
<NoAuthGuard testId="no-auth-confirm-revoke">
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</NoAuthGuard>
</AuthZTooltip>
</>
);

View File

@@ -49,6 +49,7 @@ import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { NoAuthGuard } from 'components/NoAuthGuard';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -436,18 +437,20 @@ function ServiceAccountDrawer({
]}
enabled={!isDeleted && !!selectedAccountId}
>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
<NoAuthGuard testId="no-auth-add-key">
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</NoAuthGuard>
</AuthZTooltip>
)}
</div>
@@ -550,16 +553,18 @@ function ServiceAccountDrawer({
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
<NoAuthGuard testId="no-auth-delete-service-account">
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</NoAuthGuard>
</AuthZTooltip>
)}
{!isDeleted && (
@@ -568,15 +573,17 @@ function ServiceAccountDrawer({
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
<NoAuthGuard testId="no-auth-save-service-account">
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</NoAuthGuard>
</div>
)}
</>

View File

@@ -28,7 +28,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as unknown as Date,
lastObservedAt: null as unknown as string,
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 Date,
lastObservedAt: null as unknown as string,
serviceAccountId: 'sa-1',
},
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
lastObservedAt: '2026-03-10T10:00:00Z',
serviceAccountId: 'sa-1',
},
];

View File

@@ -0,0 +1,137 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { screen, waitFor } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof renderWithNoAuth> {
return renderWithNoAuth(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
function setupBaseHandlers(): void {
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
}
describe('ServiceAccountDrawer — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
setupBaseHandlers();
});
afterEach(() => {
server.resetHandlers();
});
it('renders no-auth guards in the Overview tab footer', async () => {
renderDrawer();
await waitFor(() => {
expect(
screen.getByTestId('no-auth-delete-service-account'),
).toBeInTheDocument();
expect(
screen.getByTestId('no-auth-save-service-account'),
).toBeInTheDocument();
});
});
it('renders no-auth guard on Add Key button in Keys tab header', async () => {
renderDrawer({ account: 'sa-1', tab: 'keys' });
await waitFor(() => {
expect(screen.getByTestId('no-auth-add-key')).toBeInTheDocument();
});
});
it('does not render no-auth guards when drawer is closed', () => {
renderDrawer({});
expect(
screen.queryByTestId('no-auth-delete-service-account'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('no-auth-save-service-account'),
).not.toBeInTheDocument();
});
});

View File

@@ -108,4 +108,7 @@ 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

@@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } 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';
@@ -24,6 +31,7 @@ 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);
@@ -36,6 +44,7 @@ export default function AIAssistantModal(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -55,6 +64,10 @@ export default function AIAssistantModal(): JSX.Element | null {
} else {
startNewConversation();
setShowHistory(false);
void logEvent(AIAssistantEvents.Opened, {
source: 'shortcut',
currentPage: normalizePage(pathname),
});
openModal();
}
return;
@@ -68,7 +81,7 @@ export default function AIAssistantModal(): JSX.Element | null {
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
}, [isOpen, openModal, closeModal, startNewConversation, pathname]);
// ── Handlers ────────────────────────────────────────────────────────────────
@@ -77,15 +90,28 @@ 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]);
}, [startNewConversation, analyticsCtx]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);

View File

@@ -5,8 +5,12 @@ 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';
@@ -32,21 +36,35 @@ 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]);
}, [startNewConversation, analyticsCtx]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {

View File

@@ -1,9 +1,13 @@
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,
@@ -25,6 +29,14 @@ 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;
}
@@ -35,7 +47,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
onClick={handleOpen}
aria-label="Open AI Assistant"
>
<Bot size={20} />

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, 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';
@@ -39,6 +43,7 @@ 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
@@ -82,14 +87,50 @@ 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],
[sendMessage, analyticsCtx, pendingClarificationHere],
);
// 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]);
}, [cancelStream, conversationId, analyticsCtx.threadId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
@@ -134,6 +175,7 @@ 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,12 +41,68 @@ 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';
@@ -94,23 +150,23 @@ function resourceRoute(
resourceId: string,
): string | null {
switch (resourceType) {
case 'dashboard':
case ResourceType.dashboard:
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case 'alert': {
case ResourceType.alert: {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case 'service':
case ResourceType.service:
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case 'saved_view':
case ResourceType.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 'logs_explorer':
case ResourceType.logs_explorer:
return ROUTES.LOGS_EXPLORER;
case 'traces_explorer':
case ResourceType.traces_explorer:
return ROUTES.TRACES_EXPLORER;
case 'metrics_explorer':
case ResourceType.metrics_explorer:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
@@ -224,6 +280,24 @@ 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) {
@@ -353,10 +427,12 @@ 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
@@ -430,13 +506,39 @@ 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: {
if (action.label) {
void sendMessage(action.label);
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);
}
break;
}
@@ -444,6 +546,12 @@ 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);
}
}
@@ -456,6 +564,13 @@ 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,13 +5,17 @@ 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';
@@ -22,6 +26,8 @@ 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';
@@ -137,6 +143,8 @@ 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;
@@ -368,6 +376,28 @@ 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,
@@ -388,9 +418,81 @@ 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';
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],
);
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
@@ -398,15 +500,17 @@ 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]);
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
// 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]);
}, [discard, fireVoiceInputEvent]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
@@ -415,7 +519,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') {
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
return undefined;
}
@@ -432,7 +536,7 @@ export default function ChatInput({
return; // ignore auto-repeat
}
pttActiveRef.current = true;
start();
startVoiceInput('shortcut');
};
const handleKeyUp = (e: KeyboardEvent): void => {
@@ -466,9 +570,10 @@ export default function ChatInput({
}, [
isSupported,
micPermission,
voiceUnavailable,
disabled,
isStreaming,
start,
startVoiceInput,
handleStopAndSend,
]);
@@ -903,7 +1008,7 @@ export default function ChatInput({
<Button
variant="ghost"
size="icon"
onClick={start}
onClick={(): void => startVoiceInput('button')}
disabled={disabled}
aria-label="Start voice input"
className={styles.micBtn}

View File

@@ -9,6 +9,7 @@ import {
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import logEvent from 'api/common/logEvent';
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
ClarificationEventDTO,
@@ -16,6 +17,8 @@ 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';
@@ -44,6 +47,8 @@ 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(
@@ -60,6 +65,18 @@ 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,
@@ -69,6 +86,10 @@ export default function ClarificationForm({
const handleCancel = (): void => {
setCancelled(true);
void logEvent(AIAssistantEvents.CancelClicked, {
threadId,
secondsSinceStart: null,
});
cancelStream(conversationId);
};

View File

@@ -5,6 +5,9 @@ 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';
@@ -136,6 +139,17 @@ 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} />
<ActionsSection actions={message.actions} messageId={message.id} />
)}
</div>
</div>

View File

@@ -8,6 +8,10 @@ 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';
@@ -54,6 +58,7 @@ export default function MessageFeedback({
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { threadId } = useAIAssistantAnalyticsContext();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -91,10 +96,21 @@ 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]);
}, [
copyToClipboard,
message.content,
message.id,
message.role,
message.blocks,
]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
@@ -107,20 +123,31 @@ 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],
[vote, message.id, submitMessageFeedback, threadId],
);
const handleSubmitNegative = useCallback((): void => {
setVote('negative');
setIsNegativeDialogOpen(false);
submitMessageFeedback(
message.id,
'negative',
negativeComment.trim() || undefined,
);
}, [message.id, negativeComment, submitMessageFeedback]);
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]);
return (
<>

View File

@@ -4,6 +4,9 @@ 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';
@@ -25,10 +28,15 @@ 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]);
}, [copyToClipboard, message.content, message.id, message.role]);
return (
<div className={styles.actions}>

View File

@@ -10,6 +10,10 @@ 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';
@@ -46,17 +50,24 @@ 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 ?? '',
);
@@ -85,9 +96,13 @@ export default function VirtualizedMessages({
if (isStreaming) {
return;
}
void logEvent(AIAssistantEvents.RegenerateClicked, {
messageId,
threadId,
});
void regenerateAssistantMessage(conversationId, messageId);
},
[conversationId, isStreaming, regenerateAssistantMessage],
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
);
// Scroll all the way to the actual bottom — including the 64px of bottom
@@ -146,7 +161,11 @@ export default function VirtualizedMessages({
color="secondary"
className={styles.emptyChip}
onClick={(): void => {
sendMessage(s.text);
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
promptId: s.text,
category: SuggestedPromptCategory.EmptyState,
});
onSendSuggestedPrompt(s.text);
}}
prefix={<s.icon size={14} />}
>

View File

@@ -1,7 +1,10 @@
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';
@@ -37,6 +40,7 @@ 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;
@@ -47,6 +51,14 @@ 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,8 +1,11 @@
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';
@@ -36,6 +39,7 @@ 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[]>([]);
@@ -52,6 +56,14 @@ 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

@@ -0,0 +1,81 @@
/**
* 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

@@ -0,0 +1,60 @@
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

@@ -186,77 +186,40 @@
display: flex;
flex-direction: column;
.section-1 {
section {
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;
height: unset;
padding: 8px;
align-items: center;
gap: 6px;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.section-1,
.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;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.delete-dashboard {
display: flex;
flex-direction: column;
align-items: start;
.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;
}
.delete-dashboard .ant-btn {
color: var(--bg-cherry-400) !important;
}
}
}

View File

@@ -211,7 +211,12 @@
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, Info, X } from '@signozhq/icons';
import { Check, ExternalLink, SolidInfoCircle, 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}
>
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>

View File

@@ -26,10 +26,13 @@
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

@@ -18,6 +18,7 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import NoAuthBanner from 'components/NoAuthBanner/NoAuthBanner';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -62,7 +63,7 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
const { user } = useAppContext();
const { user, isNoAuthMode } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();
@@ -196,7 +197,7 @@ export default function Home(): JSX.Element {
const { mutate: updateUserPreference } = useMutation(updateUserPreferenceAPI, {
onSuccess: () => {
setUpdatingUserPreferences(false);
refetchUserPreferences();
void refetchUserPreferences();
},
onError: () => {
setUpdatingUserPreferences(false);
@@ -204,7 +205,7 @@ export default function Home(): JSX.Element {
});
const handleWillDoThisLater = (): void => {
logEvent('Welcome Checklist: Will do this later clicked', {});
void logEvent('Welcome Checklist: Will do this later clicked', {});
setUpdatingUserPreferences(true);
updateUserPreference({
@@ -271,11 +272,12 @@ export default function Home(): JSX.Element {
}, [metricsOnboardingData, handleUpdateChecklistDoneItem]);
useEffect(() => {
logEvent('Homepage: Visited', {});
void logEvent('Homepage: Visited', {});
}, []);
return (
<div className="home-container">
{isNoAuthMode && <NoAuthBanner />}
<div className="sticky-header">
<Header
leftComponent={
@@ -298,9 +300,9 @@ export default function Home(): JSX.Element {
autoAdjustOverflow
onOpenChange={(visible): void => {
if (visible) {
logEvent('Welcome Checklist: Expanded', {});
void logEvent('Welcome Checklist: Expanded', {});
} else {
logEvent('Welcome Checklist: Minimized', {});
void logEvent('Welcome Checklist: Minimized', {});
}
}}
content={renderWelcomeChecklistModal()}
@@ -353,7 +355,7 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
onClick={(e: React.MouseEvent): void => {
// eslint-disable-next-line sonarjs/no-duplicate-string
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
safeNavigate(ROUTES.LOGS_EXPLORER, {
@@ -362,7 +364,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
@@ -396,7 +398,7 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
safeNavigate(ROUTES.TRACES_EXPLORER, {
@@ -405,7 +407,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
@@ -439,7 +441,7 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
safeNavigate(ROUTES.METRICS_EXPLORER, {
@@ -448,7 +450,7 @@ export default function Home(): JSX.Element {
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
logEvent('Homepage: Ingestion Active Explore clicked', {
void logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER);
@@ -496,7 +498,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
void logEvent('Homepage: Explore clicked', {
source: 'Logs',
});
safeNavigate(ROUTES.LOGS_EXPLORER, {
@@ -513,7 +515,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
void logEvent('Homepage: Explore clicked', {
source: 'Traces',
});
safeNavigate(ROUTES.TRACES_EXPLORER, {
@@ -530,7 +532,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Wrench size={14} />}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
void logEvent('Homepage: Explore clicked', {
source: 'Metrics',
});
safeNavigate(ROUTES.METRICS_EXPLORER_EXPLORER, {
@@ -569,7 +571,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Plus size={14} />}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
void logEvent('Homepage: Explore clicked', {
source: 'Dashboards',
});
safeNavigate(ROUTES.ALL_DASHBOARD, {
@@ -614,7 +616,7 @@ export default function Home(): JSX.Element {
className="periscope-btn secondary"
prefix={<Plus size={14} />}
onClick={(e: React.MouseEvent): void => {
logEvent('Homepage: Explore clicked', {
void logEvent('Homepage: Explore clicked', {
source: 'Alerts',
});
safeNavigate(ROUTES.ALERTS_NEW, {

View File

@@ -438,9 +438,7 @@ function MultiIngestionSettings(): JSX.Element {
data: {
name: values.name,
tags: updatedTags,
expires_at: new Date(
dayjs(values.expires_at).endOf('day').toISOString(),
),
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
},
},
{
@@ -471,13 +469,11 @@ function MultiIngestionSettings(): JSX.Element {
const requestPayload = {
name: values.name,
tags: updatedTags,
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
expires_at: 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: new Date(TEST_EXPIRES_AT),
expires_at: TEST_EXPIRES_AT,
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [
{
@@ -160,12 +160,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Logs',
expires_at: new Date(TEST_EXPIRES_AT),
expires_at: TEST_EXPIRES_AT,
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [
{
@@ -238,12 +238,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: KEY_NAME,
expires_at: new Date(TEST_EXPIRES_AT),
expires_at: TEST_EXPIRES_AT,
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [
{
@@ -299,12 +299,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Regular',
expires_at: new Date(TEST_EXPIRES_AT),
expires_at: TEST_EXPIRES_AT,
value: 'secret1',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [],
},
@@ -319,12 +319,12 @@ describe('MultiIngestionSettings Page', () => {
keys: [
{
name: 'Key Search Result',
expires_at: new Date(TEST_EXPIRES_AT),
expires_at: TEST_EXPIRES_AT,
value: 'secret2',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [],
},

View File

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

View File

@@ -9,6 +9,7 @@ import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import useUrlQuery from 'hooks/useUrlQuery';
import { toISOString } from 'utils/app';
@@ -21,7 +22,6 @@ const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
@@ -146,7 +146,7 @@ function MembersSettings(): JSX.Element {
: `Deleted ⎯ ${deletedCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();
void refetchUsers();
}, [refetchUsers]);
const handleRowClick = useCallback((member: MemberRow): void => {
@@ -158,7 +158,7 @@ function MembersSettings(): JSX.Element {
}, []);
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
void refetchUsers();
}, [refetchUsers]);
return (
@@ -201,14 +201,16 @@ function MembersSettings(): JSX.Element {
/>
</div>
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
<NoAuthGuard testId="no-auth-invite-member">
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</NoAuthGuard>
</div>
</div>
<MembersTable

View File

@@ -20,7 +20,7 @@ const mockUsers: TypesUserDTO[] = [
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
createdAt: '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: new Date('2024-01-02T00:00:00.000Z'),
createdAt: '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: new Date('2024-01-03T00:00:00.000Z'),
createdAt: '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: new Date('2024-01-04T00:00:00.000Z'),
createdAt: '2024-01-04T00:00:00.000Z',
orgId: 'org-1',
},
];

View File

@@ -0,0 +1,51 @@
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import { screen } from 'tests/test-utils';
import MembersSettings from '../MembersSettings';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const USERS_ENDPOINT = '*/api/v2/users';
const mockUsers: TypesUserDTO[] = [
{
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
orgId: 'org-1',
},
];
describe('MembersSettings — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockUsers })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the no-auth sentinel and disables the Invite member button', async () => {
renderWithNoAuth(<MembersSettings />);
await screen.findByText('Alice Smith');
expect(screen.getByTestId('no-auth-invite-member')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /invite member/i })).toBeDisabled();
});
});

View File

@@ -7,6 +7,7 @@ import {
updateMyPassword,
useUpdateMyUserV2,
} from 'api/generated/services/users';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, Mail, User } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -80,10 +81,10 @@ function UserInfo(): JSX.Element {
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {
logEvent('Account Settings: Name Updated', {
void logEvent('Account Settings: Name Updated', {
name: changedName,
});
logEvent(
void logEvent(
'Account Settings: Name Updated',
{
name: changedName,
@@ -135,23 +136,27 @@ function UserInfo(): JSX.Element {
</div>
<div className="user-info-update-section">
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsUpdateNameModalOpen(true)}
>
Update name
</Button>
<NoAuthGuard testId="no-auth-update-name">
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsUpdateNameModalOpen(true)}
>
Update name
</Button>
</NoAuthGuard>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
<NoAuthGuard testId="no-auth-reset-password">
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
</NoAuthGuard>
</div>
<Modal
@@ -161,16 +166,17 @@ function UserInfo(): JSX.Element {
closable
onCancel={hideUpdateNameModal}
footer={[
<Button
key="submit"
type="primary"
icon={<Check size={16} />}
onClick={onSaveHandler}
disabled={isLoading}
data-testid="update-name-btn"
>
Update name
</Button>,
<NoAuthGuard key="submit" testId="no-auth-update-name-submit">
<Button
type="primary"
icon={<Check size={16} />}
onClick={onSaveHandler}
disabled={isLoading}
data-testid="update-name-btn"
>
Update name
</Button>
</NoAuthGuard>,
]}
>
<Typography.Text>Name</Typography.Text>

View File

@@ -0,0 +1,33 @@
import UserInfo from 'container/MySettings/UserInfo';
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useUpdateMyUserV2: jest.fn(() => ({
mutateAsync: jest.fn(),
isLoading: false,
})),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
error: jest.fn(),
success: jest.fn(),
},
}),
}));
describe('UserInfo — no-auth mode', () => {
it('renders no-auth guard wrappers for Update name and Reset password buttons', () => {
renderWithNoAuth(<UserInfo />);
expect(screen.getByTestId('no-auth-update-name')).toBeInTheDocument();
expect(screen.getByTestId('no-auth-reset-password')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
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,13 +1,17 @@
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 { handleQueryChange } from '../utils';
import { getIsQueryModified, handleQueryChange } from '../utils';
const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
queryType: EQueryType.QUERY_BUILDER,
@@ -37,6 +41,128 @@ 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,18 +1,17 @@
/* 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, SolidAlertTriangle, X } from '@signozhq/icons';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/ui/resizable';
import { Flex, Modal, Space } from 'antd';
import { Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
@@ -69,7 +68,6 @@ 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';
@@ -82,6 +80,7 @@ import {
placeWidgetAtBottom,
placeWidgetBetweenRows,
} from './utils';
import DiscardChangesModal from './WidgetModals/DiscardChangesModal';
import './NewWidget.styles.scss';
@@ -98,8 +97,6 @@ function NewWidget({
const { dashboardVariables } = useDashboardVariables();
const { t } = useTranslation(['dashboard']);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const {
@@ -110,11 +107,6 @@ function NewWidget({
setSupersetQuery,
} = useQueryBuilder();
const isQueryModified = useMemo(
() => getIsQueryModified(currentQuery, stagedQuery),
[currentQuery, stagedQuery],
);
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -139,6 +131,23 @@ 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);
@@ -228,7 +237,6 @@ function NewWidget({
Record<string, string>
>(selectedWidget?.customLegendColors || {});
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
const [bucketWidth, setBucketWidth] = useState<number>(
@@ -340,7 +348,6 @@ function NewWidget({
]);
const closeModal = (): void => {
setSaveModal(false);
setDiscardModal(false);
};
@@ -593,7 +600,7 @@ function NewWidget({
},
};
updateDashboardMutation.mutateAsync(dashboard, {
return updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || '');
navigateToDashboardPage();
@@ -688,9 +695,9 @@ function NewWidget({
})),
}),
});
setSaveModal(true);
onClickSaveHandler();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel]);
}, [onClickSaveHandler]);
const isNewTraceLogsAvailable =
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
@@ -951,57 +958,14 @@ function NewWidget({
</ResizablePanel>
</ResizablePanelGroup>
</PanelContainer>
<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
<DiscardChangesModal
open={discardModal}
width={600}
>
<Typography>{t('dashboard_unsave_changes')}</Typography>
</Modal>
isNewPanel={isNewPanel}
panelTitle={title}
dashboardTitle={dashboardData?.data?.title}
onDiscard={discardChanges}
onClose={closeModal}
/>
</Container>
);
}

View File

@@ -1,5 +1,4 @@
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 {
@@ -26,16 +25,84 @@ 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,
stagedQuery: Query | null,
baselineQuery: Query | null | undefined,
): boolean => {
if (!stagedQuery) {
if (!baselineQuery) {
return false;
}
const omitIdFromStageQuery = omitIdFromQuery(stagedQuery);
const omitIdFromCurrentQuery = omitIdFromQuery(currentQuery);
return !isEqual(omitIdFromStageQuery, omitIdFromCurrentQuery);
return !isEqual(
stripUndefined(normalizeForDirtyCheck(baselineQuery)),
stripUndefined(normalizeForDirtyCheck(currentQuery)),
);
};
export type PartialPanelTypes = {

View File

@@ -8,7 +8,9 @@ import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import AuthError from 'components/AuthError/AuthError';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { cloneDeep, debounce } from 'lodash-es';
import {
ArrowRight,
@@ -140,6 +142,8 @@ function InviteTeamMembers({
}, 1000);
};
const { isNoAuthMode } = useAppContext();
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
@@ -257,7 +261,7 @@ function InviteTeamMembers({
const hasInvites =
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
const isButtonDisabled = isSendingInvites || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
const isInviteButtonDisabled = isButtonDisabled || !hasInvites || isNoAuthMode;
return (
<div className="questions-container">
@@ -365,24 +369,26 @@ function InviteTeamMembers({
)}
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={handleNext}
disabled={isInviteButtonDisabled}
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
<NoAuthGuard testId="no-auth-onboarding-invite">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={handleNext}
disabled={isInviteButtonDisabled}
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
</NoAuthGuard>
<Button
variant="ghost"
color="secondary"

View File

@@ -0,0 +1,31 @@
import InviteTeamMembers from 'container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers';
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
error: jest.fn(),
success: jest.fn(),
},
}),
}));
describe('OnboardingQuestionaire InviteTeamMembers — no-auth mode', () => {
it('renders no-auth guard wrapper for the invite button', () => {
renderWithNoAuth(
<InviteTeamMembers
isLoading={false}
teamMembers={null}
setTeamMembers={jest.fn()}
onNext={jest.fn()}
/>,
);
expect(screen.getByTestId('no-auth-onboarding-invite')).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { isModifierKeyPressed } from 'utils/app';
import { NoAuthGuard } from 'components/NoAuthGuard';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
@@ -209,7 +210,7 @@ function OnboardingAddDataSource(): JSX.Element {
};
useEffect(() => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.STARTED}`,
{},
);
@@ -253,7 +254,7 @@ function OnboardingAddDataSource(): JSX.Element {
setSelectedFramework(null);
setSelectedEnvironment(null);
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SELECTED}`,
{
dataSource: dataSource.label,
@@ -276,7 +277,7 @@ function OnboardingAddDataSource(): JSX.Element {
};
const handleSelectFramework = (option: any): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.FRAMEWORK_SELECTED}`,
{
dataSource: selectedDataSource?.label,
@@ -309,7 +310,7 @@ function OnboardingAddDataSource(): JSX.Element {
selectedEnvironment: any,
baseURL?: string,
): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.ENVIRONMENT_SELECTED}`,
{
dataSource: selectedDataSource?.label,
@@ -351,7 +352,7 @@ function OnboardingAddDataSource(): JSX.Element {
groupDataSourcesByTags(filteredDataSources as Entity[]),
);
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SEARCHED}`,
{
searchedDataSource: query,
@@ -485,7 +486,7 @@ function OnboardingAddDataSource(): JSX.Element {
};
const handleShowInviteTeamMembersModal = (): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
{
dataSource: selectedDataSource?.label,
@@ -498,7 +499,7 @@ function OnboardingAddDataSource(): JSX.Element {
};
const handleSubmitDataSourceRequest = (): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
{
requestedDataSource: dataSourceRequest,
@@ -513,7 +514,7 @@ function OnboardingAddDataSource(): JSX.Element {
};
const handleRaiseRequest = (): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
{
requestedDataSource: searchQuery,
@@ -635,7 +636,7 @@ function OnboardingAddDataSource(): JSX.Element {
size={14}
className="onboarding-header-container-close-icon"
onClick={(e): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CLOSE_ONBOARDING_CLICKED}`,
{
currentPage: setupStepItems[currentStep]?.title || '',
@@ -649,14 +650,16 @@ function OnboardingAddDataSource(): JSX.Element {
</div>
<div className="header-right-section">
<Button
type="default"
className="periscope-btn invite-teammate-btn outlined"
onClick={handleShowInviteTeamMembersModal}
icon={<UserPlus size={16} />}
>
Invite a teammate
</Button>
<NoAuthGuard testId="no-auth-invite-teammate">
<Button
type="default"
className="periscope-btn invite-teammate-btn outlined"
onClick={handleShowInviteTeamMembersModal}
icon={<UserPlus size={16} />}
>
Invite a teammate
</Button>
</NoAuthGuard>
<LaunchChatSupport
attributes={{
@@ -970,7 +973,7 @@ function OnboardingAddDataSource(): JSX.Element {
disabled={!selectedDataSource}
shape="round"
onClick={(e): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONFIGURED_PRODUCT}`,
{
dataSource: selectedDataSource?.label,
@@ -1038,7 +1041,7 @@ function OnboardingAddDataSource(): JSX.Element {
type="default"
shape="round"
onClick={(): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BACK_BUTTON_CLICKED}`,
{
dataSource: selectedDataSource?.label,
@@ -1057,7 +1060,7 @@ function OnboardingAddDataSource(): JSX.Element {
type="primary"
shape="round"
onClick={(e): void => {
logEvent(
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONTINUE_BUTTON_CLICKED}`,
{
dataSource: selectedDataSource?.label,

View File

@@ -0,0 +1,31 @@
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import OnboardingAddDataSource from '../AddDataSource';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('api/generated/services/global', () => ({
useGetGlobalConfig: jest.fn(() => ({ data: undefined })),
}));
jest.mock('components/LaunchChatSupport/LaunchChatSupport', () => ({
__esModule: true,
default: (): JSX.Element => <button type="button">Contact Support</button>,
}));
describe('OnboardingAddDataSource — no-auth mode', () => {
it('renders no-auth guard wrapper for the invite teammate button', () => {
renderWithNoAuth(<OnboardingAddDataSource />);
expect(screen.getByTestId('no-auth-invite-teammate')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,32 @@
import InviteTeamMembers from 'container/OnboardingV2Container/InviteTeamMembers/InviteTeamMembers';
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
error: jest.fn(),
success: jest.fn(),
},
}),
}));
describe('OnboardingV2Container InviteTeamMembers — no-auth mode', () => {
it('renders no-auth guard wrapper for the invite button', () => {
renderWithNoAuth(
<InviteTeamMembers
isLoading={false}
teamMembers={null}
setTeamMembers={jest.fn()}
onNext={jest.fn()}
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('no-auth-v2-invite')).toBeInTheDocument();
});
});

View File

@@ -5,6 +5,7 @@ import { Button, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import {
@@ -281,15 +282,17 @@ function InviteTeamMembers({
Cancel
</Button>
<Button
type="primary"
className="next-button periscope-btn primary"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
Send Invites
<ArrowRight size={14} />
</Button>
<NoAuthGuard testId="no-auth-v2-invite">
<Button
type="primary"
className="next-button periscope-btn primary"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
Send Invites
<ArrowRight size={14} />
</Button>
</NoAuthGuard>
</div>
</div>
);

View File

@@ -0,0 +1,32 @@
import { fireEvent, screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import CreateEdit from './CreateEdit';
import { mockGoogleAuthDomain } from '../__tests__/mocks';
describe('CreateEdit — no-auth mode', () => {
it('renders no-auth guard sentinel for Save Changes button', () => {
renderWithNoAuth(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('no-auth-save-auth-domain')).toBeInTheDocument();
});
it('renders no-auth guard sentinel for Save Changes button in create mode after selecting provider', async () => {
renderWithNoAuth(<CreateEdit isCreate onClose={jest.fn()} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
fireEvent.click(configureButtons[0]);
await expect(
screen.findByTestId('no-auth-save-auth-domain'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -17,6 +17,7 @@ import {
import { AxiosError } from 'axios';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
@@ -257,14 +258,16 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
Cancel
</Button>
)}
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
<NoAuthGuard testId="no-auth-save-auth-domain">
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
</NoAuthGuard>
</section>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
@@ -65,7 +66,9 @@ function SSOEnforcementToggle({
};
return (
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
<NoAuthGuard testId="no-auth-sso-toggle">
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
</NoAuthGuard>
);
}

View File

@@ -0,0 +1,26 @@
import { rest, server } from 'mocks-server/server';
import { screen, waitFor } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import AuthDomain from '../index';
import { AUTH_DOMAINS_LIST_ENDPOINT, mockEmptyDomainsResponse } from './mocks';
describe('AuthDomain — no-auth mode', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders no-auth guard sentinel for Add Domain button', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
renderWithNoAuth(<AuthDomain />);
await waitFor(() => {
expect(screen.getByTestId('no-auth-add-domain')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,36 @@
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
jest.mock('@signozhq/ui/switch', () => ({
...jest.requireActual('@signozhq/ui/switch'),
Switch: ({
value,
disabled,
}: {
value: boolean;
disabled?: boolean;
}): JSX.Element => (
<button
type="button"
role="switch"
aria-checked={value}
disabled={disabled}
/>
),
}));
import SSOEnforcementToggle from '../SSOEnforcementToggle';
import { mockGoogleAuthDomain } from './mocks';
describe('SSOEnforcementToggle — no-auth mode', () => {
it('renders no-auth guard sentinel when isNoAuthMode is true', () => {
renderWithNoAuth(
<SSOEnforcementToggle
isDefaultChecked={false}
record={mockGoogleAuthDomain}
/>,
);
expect(screen.getByTestId('no-auth-sso-toggle')).toBeInTheDocument();
});
});

View File

@@ -14,6 +14,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { NoAuthGuard } from 'components/NoAuthGuard';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -75,7 +76,7 @@ function AuthDomain(): JSX.Element {
{
onSuccess: () => {
toast.success('Domain deleted successfully');
refetchAuthDomainListResponse();
void refetchAuthDomainListResponse();
hideDeleteModal();
},
onError: (error) => {
@@ -153,20 +154,24 @@ function AuthDomain(): JSX.Element {
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
<NoAuthGuard testId="no-auth-configure-sso">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
</NoAuthGuard>
<NoAuthGuard testId="no-auth-delete-domain">
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</NoAuthGuard>
</section>
),
},
@@ -178,17 +183,19 @@ function AuthDomain(): JSX.Element {
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
<NoAuthGuard testId="no-auth-add-domain">
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
</NoAuthGuard>
</section>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
@@ -231,15 +238,16 @@ function AuthDomain(): JSX.Element {
>
Cancel
</Button>,
<Button
key="submit"
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
<NoAuthGuard key="submit" testId="no-auth-delete-domain-confirm">
<Button
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>
</NoAuthGuard>,
]}
>
<p className="delete-text">

View File

@@ -0,0 +1,37 @@
import { Form } from 'antd';
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
error: jest.fn(),
success: jest.fn(),
},
}),
}));
jest.mock('api/v1/invite/create', () => ({
__esModule: true,
default: jest.fn(),
}));
function TestWrapper(): JSX.Element {
const [form] = Form.useForm();
return (
<InviteUserModal
isInviteTeamMemberModalOpen
toggleModal={jest.fn()}
form={form}
onClose={jest.fn()}
/>
);
}
describe('InviteUserModal — no-auth mode', () => {
it('renders no-auth guard wrapper for the invite submit button', () => {
renderWithNoAuth(<TestWrapper />);
expect(screen.getByTestId('no-auth-invite-user')).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, FormInstance, Modal } from 'antd';
import { NoAuthGuard } from 'components/NoAuthGuard';
import sendInvite from 'api/v1/invite/create';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
@@ -85,16 +86,20 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
ns: 'common',
})}
</Button>,
<Button
<NoAuthGuard
key={t('invite_team_members').toString()}
onClick={modalForm.submit}
data-testid="invite-team-members-button"
type="primary"
disabled={isInvitingMembers}
loading={isInvitingMembers}
testId="no-auth-invite-user"
>
{t('invite_team_members')}
</Button>,
<Button
onClick={modalForm.submit}
data-testid="invite-team-members-button"
type="primary"
disabled={isInvitingMembers}
loading={isInvitingMembers}
>
{t('invite_team_members')}
</Button>
</NoAuthGuard>,
]}
>
<InviteTeamMembers form={modalForm} onFinish={onInviteClickHandler} />

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check, Info } from '@signozhq/icons';
import { Check } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -11,7 +11,6 @@ import {
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -21,9 +20,9 @@ import {
updateDowntimeScheduleByID,
} from 'api/generated/services/downtimeschedules';
import type {
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesPostablePlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesPostablePlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
@@ -47,8 +46,6 @@ import { AlertRuleTags } from './PlannedDowntimeList';
import {
getAlertOptionsFromIds,
getDurationInfo,
getEndTime,
handleTimeConversion,
isScheduleRecurring,
recurrenceOptions,
recurrenceOptionWithSubmenu,
@@ -65,25 +62,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,
}),
);
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | string;
endTime: dayjs.Dayjs | string;
recurrence?: AlertmanagertypesRecurrenceDTO | null;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
recurrence?: RuletypesRecurrenceDTO;
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
recurrenceSelect?: RuletypesRecurrenceDTO;
timezone?: string;
labelExpression?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: Partial<
AlertmanagertypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>;
initialValues: Partial<RuletypesPlannedMaintenanceDTO>;
alertOptions: DefaultOptionType[];
isError: boolean;
isLoading: boolean;
@@ -91,7 +91,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance<any>;
form: FormInstance;
}
export function PlannedDowntimeForm(
@@ -109,67 +109,46 @@ export function PlannedDowntimeForm(
form,
} = props;
const [selectedTags, setSelectedTags] = React.useState<
DefaultOptionType | DefaultOptionType[]
>([]);
const [selectedTags, setSelectedTags] = React.useState<DefaultOptionType[]>(
[],
);
const alertRuleFormName = 'alertRules';
const [saveLoading, setSaveLoading] = useState(false);
const [durationUnit, setDurationUnit] = useState<string>(
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.unit || 'm',
getDurationInfo(initialValues.schedule?.recurrence?.duration)?.unit || 'm',
);
const [formData, setFormData] = useState<Partial<PlannedDowntimeFormData>>({
timezone: initialValues.schedule?.timezone,
});
const [recurrenceType, setRecurrenceType] = useState<string | null>(
(initialValues.schedule?.recurrence?.repeatType as string) ||
const [recurrenceType, setRecurrenceType] = useState<string>(
initialValues.schedule?.recurrence?.repeatType ||
recurrenceOptions.doesNotRepeat.value,
);
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 saveHanlder = useCallback(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
labelExpression: values.labelExpression,
schedule: {
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,
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone: values.timezone!,
recurrence: values.recurrence,
},
};
@@ -201,50 +180,58 @@ export function PlannedDowntimeForm(
notifications,
refetchAllSchedules,
setIsOpen,
timezoneInitialValue,
showErrorModal,
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
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,
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
const payloadValues = {
await saveHandler({
...values,
recurrence: recurrenceData as AlertmanagertypesRecurrenceDTO | undefined,
};
await saveHanlder(payloadValues);
recurrence: recurrenceData,
});
};
const formValidationRules = [
{
required: true,
},
];
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 handleOk = async (): Promise<void> => {
await form.validateFields().catch(() => {
@@ -252,16 +239,11 @@ export function PlannedDowntimeForm(
});
};
const handleCancel = (): void => {
setIsOpen(false);
};
const handleCancel = (): void => setIsOpen(false);
const handleChange = (
_value: string,
options: DefaultOptionType | DefaultOptionType[],
): void => {
const handleAlertRulesChange: SelectProps['onChange'] = (_value, options) => {
form.setFieldValue(alertRuleFormName, options);
setSelectedTags(options);
setSelectedTags(Array.isArray(options) ? options : [options]);
};
const noTagRenderer: SelectProps['tagRender'] = () => <></>;
@@ -270,114 +252,51 @@ export function PlannedDowntimeForm(
if (!removedTag) {
return;
}
const newTags = selectedTags.filter(
(tag: DefaultOptionType) => tag.value !== removedTag,
);
const newTags = selectedTags.filter((tag) => tag.value !== removedTag);
form.setFieldValue(alertRuleFormName, newTags);
setSelectedTags(newTags);
};
const formatedInitialValues = useMemo(() => {
const formData: PlannedDowntimeFormData = {
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
return {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
startTime: initialValues.schedule?.startTime
? dayjs(initialValues.schedule?.startTime)
: '',
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues?.schedule)
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)
? recurrenceOptions.doesNotRepeat.value
: initialValues.schedule?.recurrence
?.repeatType) as AlertmanagertypesRecurrenceDTO['repeatType'],
duration: String(
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.value ?? '',
),
} as AlertmanagertypesRecurrenceDTO,
timezone: initialValues.schedule?.timezone as string,
labelExpression: initialValues.labelExpression || '',
: schedule?.recurrence?.repeatType,
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as RuletypesRecurrenceDTO,
timezone: schedule?.timezone as string,
};
return formData;
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formatedInitialValues.alertRules);
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);
};
setSelectedTags(formattedInitialValues.alertRules);
form.setFieldsValue({ ...formattedInitialValues });
}, [form, formattedInitialValues, initialValues]);
const startTimeText = useMemo((): string => {
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
}
const startTime = formData.startTime;
if (!startTime) {
return '';
}
if (formData.timezone) {
startTime = handleTimeConversion(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
const daysOfWeek = formData.recurrence?.repeatOn;
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 formattedStartTime = startTime.format(TIME_FORMAT);
const formattedStartDate = startTime.format(DATE_FORMAT);
const ordinalFormat = startTime.format(ORDINAL_FORMAT);
const formattedDaysOfWeek = daysOfWeek?.join(', ');
switch (recurrenceType) {
@@ -392,49 +311,18 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
}, [formData, recurrenceType, timezone]);
const endTimeText = useMemo((): string => {
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime =
(formData?.recurrence?.endTime
? dayjs(formData.recurrence.endTime).toISOString()
: '') || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';
}
}
const endTime = formData.endTime;
if (!endTime) {
return '';
}
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,
);
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
}, [formData, recurrenceType, timezone]);
return (
<Modal
@@ -450,33 +338,28 @@ export function PlannedDowntimeForm(
footer={null}
>
<Form<PlannedDowntimeFormData>
name={initialValues.editMode ? 'edit-form' : 'create-form'}
name={isEditMode ? 'edit-form' : 'create-form'}
form={form}
layout="vertical"
className="createForm"
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setFormData(form.getFieldsValue());
handleFormData(form.getFieldsValue());
}}
autoComplete="off"
>
<Form.Item label="Name" name="name" rules={formValidationRules}>
<Form.Item label="Name" name="name" rules={requiredFieldRule}>
<Input placeholder="e.g. Upgrade downtime" />
</Form.Item>
<Form.Item
label="Starts from"
name="startTime"
rules={formValidationRules}
rules={requiredFieldRule}
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
<DatePicker
format={(date): string =>
dayjs(date).tz(timezoneInitialValue).format(customFormat)
}
format={(date) => date.format(customFormat)}
showTime
renderExtraFooter={datePickerFooter}
showNow={false}
@@ -489,7 +372,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Repeats every"
name={['recurrence', 'repeatType']}
rules={formValidationRules}
rules={requiredFieldRule}
>
<Select
placeholder="Select option..."
@@ -500,7 +383,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Weekly occurernce"
name={['recurrence', 'repeatOn']}
rules={formValidationRules}
rules={requiredFieldRule}
>
<Select
placeholder="Select option..."
@@ -514,16 +397,14 @@ export function PlannedDowntimeForm(
<Form.Item
label="Duration"
name={['recurrence', 'duration']}
rules={formValidationRules}
rules={requiredFieldRule}
>
<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>
@@ -537,8 +418,8 @@ export function PlannedDowntimeForm(
/>
</Form.Item>
)}
<Form.Item label="Timezone" name="timezone" rules={formValidationRules}>
<Select options={timeZoneItems} placeholder="Select timezone" showSearch />
<Form.Item label="Timezone" name="timezone" rules={requiredFieldRule}>
<Select options={TZ_OPTIONS} placeholder="Select timezone" showSearch />
</Form.Item>
<Form.Item
label="Ends on"
@@ -550,14 +431,9 @@ export function PlannedDowntimeForm(
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
<DatePicker
format={(date): string =>
dayjs(date).tz(timezoneInitialValue).format(customFormat)
}
format={(date) => date.format(customFormat)}
showTime
showNow={false}
renderExtraFooter={datePickerFooter}
@@ -588,7 +464,7 @@ export function PlannedDowntimeForm(
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
@@ -612,22 +488,6 @@ export function PlannedDowntimeForm(
</Select>
</Form.Item>
</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 { ReactNode, useEffect } from 'react';
import React, { 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';
@@ -7,8 +7,8 @@ import type { DefaultOptionType } from 'antd/es/select';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import cx from 'classnames';
@@ -19,12 +19,11 @@ 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';
@@ -126,29 +125,28 @@ export function CollapseListContent({
created_at,
created_by_name,
created_by_email,
timeframe,
repeats,
schedule,
updated_at,
updated_by_name,
alertOptions,
timezone,
}: {
created_at?: string;
created_by_name?: string;
created_by_email?: string;
timeframe: [string | undefined | null, string | undefined | null];
repeats?: AlertmanagertypesRecurrenceDTO | null;
schedule?: RuletypesScheduleDTO;
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>
@@ -183,16 +181,20 @@ export function CollapseListContent({
{renderItems(
'Timeframe',
timeframe[0] || timeframe[1] ? (
<Typography>{`${formatDateTime(timeframe[0])}${formatDateTime(
timeframe[1],
)}`}</Typography>
schedule?.startTime ? (
<Typography>{`${startTime}${endTime}`}</Typography>
) : (
'-'
),
)}
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
{renderItems(
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -212,7 +214,7 @@ export function CollapseListContent({
export function CustomCollapseList(
props: DowntimeSchedulesTableData & {
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<AlertmanagertypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
@@ -232,22 +234,12 @@ export function CustomCollapseList(
setModalOpen,
handleDeleteDowntime,
setEditMode,
kind,
} = props;
const scheduleTime = schedule?.startTime
? 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>);
? dayjs(schedule.startTime).tz(schedule.timezone)
: createdAt || '';
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(scheduleTime)} ${schedule?.timezone}`;
return (
<>
@@ -257,21 +249,16 @@ export function CustomCollapseList(
<HeaderComponent
duration={
schedule?.recurrence?.duration
? (schedule?.recurrence?.duration as string)
: getDuration(
schedule?.startTime ? dayjs(schedule.startTime).toISOString() : '',
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '',
)
? schedule.recurrence.duration
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
}
name={defaultTo(name, '')}
handleEdit={(): void => {
handleEdit={() => {
setInitialValues({ ...props });
setModalOpen(true);
setEditMode(true);
}}
handleDelete={(): void => {
handleDeleteDowntime(id ?? '', name || '');
}}
handleDelete={() => handleDeleteDowntime(id ?? '', name || '')}
/>
}
key={id ?? ''}
@@ -279,17 +266,10 @@ export function CustomCollapseList(
<CollapseListContent
created_at={createdAt ? dayjs(createdAt).toISOString() : ''}
created_by_name={defaultTo(createdBy, '')}
timeframe={[
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={
schedule?.recurrence as AlertmanagertypesRecurrenceDTO | null | undefined
}
schedule={schedule}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
timezone={defaultTo(schedule?.timezone, '')}
/>
</Panel>
</Collapse>
@@ -301,10 +281,9 @@ export function CustomCollapseList(
);
}
export type DowntimeSchedulesTableData =
AlertmanagertypesPlannedMaintenanceDTO & {
alertOptions: DefaultOptionType[];
};
export type DowntimeSchedulesTableData = RuletypesPlannedMaintenanceDTO & {
alertOptions: DefaultOptionType[];
};
export function PlannedDowntimeList({
downtimeSchedules,
@@ -321,7 +300,7 @@ export function PlannedDowntimeList({
>;
alertOptions: DefaultOptionType[];
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<AlertmanagertypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;

View File

@@ -5,14 +5,14 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { isEmpty, isEqual } from 'lodash-es';
import dayjs, { Dayjs } from 'dayjs';
import { isEmpty } from 'lodash-es';
import APIError from 'types/api/error';
type DateTimeString = string | null | undefined;
@@ -38,14 +38,20 @@ export const getDuration = (
return `${hours} hours`;
};
export const formatDateTime = (dateTimeString?: string | null): string => {
export const formatDateTime = (
dateTimeString?: string | Dayjs | null,
timezone?: string,
): string => {
if (!dateTimeString) {
return 'N/A';
}
return dayjs(dateTimeString.slice(0, 19)).format(
DATE_TIME_FORMATS.MONTH_DATETIME,
);
let dt = dayjs(dateTimeString);
if (timezone) {
dt = dt.tz(timezone);
}
return dt.format(DATE_TIME_FORMATS.MONTH_DATETIME);
};
export const getAlertOptionsFromIds = (
@@ -60,7 +66,8 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
recurrence?: AlertmanagertypesRecurrenceDTO | null,
recurrence?: RuletypesRecurrenceDTO | null,
timezone?: string,
): string => {
if (!recurrence) {
return 'No';
@@ -69,10 +76,10 @@ export const recurrenceInfo = (
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(dayjs(startTime).toISOString())
? formatDateTime(startTime, timezone)
: '';
const formattedEndTime = endTime
? `to ${formatDateTime(dayjs(endTime).toISOString())}`
? `to ${formatDateTime(endTime, timezone)}`
: '';
const weeklyRepeatString = repeatOn ? `on ${repeatOn.join(', ')}` : '';
const durationString = duration ? `- Duration: ${duration}` : '';
@@ -80,9 +87,7 @@ export const recurrenceInfo = (
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
};
export const defautlInitialValues: Partial<
AlertmanagertypesPlannedMaintenanceDTO & { editMode: boolean }
> = {
export const defaultInitialValues: Partial<RuletypesPlannedMaintenanceDTO> = {
name: '',
description: '',
schedule: {
@@ -94,7 +99,6 @@ export const defautlInitialValues: Partial<
alertIds: [],
createdAt: undefined,
createdBy: undefined,
editMode: false,
};
type DeleteDowntimeScheduleProps = {
@@ -210,75 +214,6 @@ 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,
schedule?: RuletypesPlannedMaintenanceDTO['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: new Date(MOCK_DATE_1),
updatedAt: new Date(MOCK_DATE_1),
createdAt: MOCK_DATE_1,
updatedAt: MOCK_DATE_1,
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_1),
startTime: 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: new Date(MOCK_DATE_2),
updatedAt: new Date(MOCK_DATE_2),
createdAt: MOCK_DATE_2,
updatedAt: MOCK_DATE_2,
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_2),
startTime: 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: new Date(MOCK_DATE_3),
updatedAt: new Date(MOCK_DATE_3),
createdAt: MOCK_DATE_3,
updatedAt: MOCK_DATE_3,
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_3),
startTime: MOCK_DATE_3,
timezone: 'UTC',
}),
alertIds: [],

View File

@@ -1,15 +1,15 @@
import type {
AlertmanagertypesScheduleDTO,
AlertmanagertypesPlannedMaintenanceDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertmanagertypesMaintenanceKindDTO,
AlertmanagertypesMaintenanceStatusDTO,
RuletypesMaintenanceKindDTO,
RuletypesMaintenanceStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
schedule: Partial<RuletypesScheduleDTO>,
): RuletypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
endTime: schedule?.endTime,
@@ -17,14 +17,14 @@ export const buildSchedule = (
});
export const createMockDowntime = (
overrides: Partial<AlertmanagertypesPlannedMaintenanceDTO>,
): AlertmanagertypesPlannedMaintenanceDTO => ({
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
): RuletypesPlannedMaintenanceDTO => ({
id: overrides.id ?? '0',
name: overrides.name ?? '',
description: overrides.description ?? '',
schedule: buildSchedule({
timezone: 'UTC',
startTime: new Date('2024-01-01'),
startTime: '2024-01-01',
...overrides.schedule,
}),
alertIds: overrides.alertIds ?? [],
@@ -32,6 +32,6 @@ export const createMockDowntime = (
createdBy: overrides.createdBy ?? '',
updatedAt: overrides.updatedAt,
updatedBy: overrides.updatedBy ?? '',
kind: overrides.kind ?? AlertmanagertypesMaintenanceKindDTO.recurring,
status: overrides.status ?? AlertmanagertypesMaintenanceStatusDTO.active,
kind: overrides.kind ?? RuletypesMaintenanceKindDTO.recurring,
status: overrides.status ?? RuletypesMaintenanceStatusDTO.active,
});

View File

@@ -16,35 +16,31 @@ 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_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
[SpanScope.ALL_SPANS]: null,
[SpanScope.ROOT_SPANS]: {
key: 'isRoot',
type: 'spanSearchScope',
},
[SpanScope.ENTRYPOINT_SPANS]: {
key: 'isEntryPoint',
type: 'spanSearchScope',
},
[SpanScope.ROOT_SPANS]: 'isRoot',
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
};
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
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 => ({
id: uuid().slice(0, 8),
key: {
key: config.key,
key,
dataType: undefined,
type: config?.type,
type: '',
},
op: '=',
value: 'true',
@@ -70,12 +66,7 @@ function SpanScopeSelector({
filters: TagFilterItem[] = [],
): SpanScope => {
const hasFilter = (key: string): boolean =>
filters?.some(
(filter) =>
filter.key?.type === 'spanSearchScope' &&
filter.key.key === key &&
filter.value === 'true',
);
filters?.some((filter) => isScopeFilter(filter, key));
if (hasFilter('isRoot')) {
return SpanScope.ROOT_SPANS;
@@ -113,28 +104,21 @@ function SpanScopeSelector({
const nonScopeFilters = currentFilters.filter(
(filter) =>
!(
filter.key?.type === 'spanSearchScope' &&
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
),
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
);
const config = SPAN_FILTER_CONFIG[newScope];
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
const scopeKey = SPAN_FILTER_KEY[newScope];
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
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 ?? '',
keysToRemove,
SCOPE_FILTER_KEYS,
),
},
filters: {

View File

@@ -20,12 +20,16 @@ import SpanScopeSelector from '../SpanScopeSelector';
const mockRedirectWithQueryBuilderData = jest.fn();
const SCOPE_KEYS = ['isRoot', 'isEntryPoint'];
const isScopeFilter = (filter: TagFilterItem): boolean =>
SCOPE_KEYS.includes(filter.key?.key ?? '') && String(filter.value) === 'true';
// Helper to create filter items
const createSpanScopeFilter = (key: string): TagFilterItem => ({
id: 'span-filter',
key: {
key,
type: 'spanSearchScope',
type: '',
},
op: '=',
value: 'true',
@@ -143,7 +147,6 @@ describe('SpanScopeSelector', () => {
expect.objectContaining({
key: expect.objectContaining({
key: expectedKey,
type: 'spanSearchScope',
}),
op: '=',
value: 'true',
@@ -162,11 +165,7 @@ describe('SpanScopeSelector', () => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
const filters = updatedQuery.builder.queryData[0].filters.items;
expect(filters).not.toContainEqual(
expect.objectContaining({
key: expect.objectContaining({ type: 'spanSearchScope' }),
}),
);
expect(filters.some(isScopeFilter)).toBe(false);
});
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
@@ -206,6 +205,27 @@ describe('SpanScopeSelector', () => {
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
// Round-trip from filter.expression can deserialize the value as a boolean
// `true` (unquoted in the expression) instead of the string `'true'` produced
// by the dropdown. The dropdown must still recognize that as the scope filter.
it.each([
['Root Spans', 'isRoot'],
['Entrypoint Spans', 'isEntryPoint'],
])(
'should initialize with %s selected when %s = true (boolean value)',
async (expectedText, filterKey) => {
const booleanScopeFilter: TagFilterItem = {
id: 'span-filter',
key: { key: filterKey, type: '' },
op: '=',
value: true as unknown as string,
};
const queryWithFilter = createQueryWithFilters([booleanScopeFilter]);
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
});
describe('when onChange and query props are provided', () => {
@@ -233,9 +253,7 @@ describe('SpanScopeSelector', () => {
expect(items).toContainEqual(nonScopeItem);
});
const scopeFiltersInPayload = items.filter(
(filter) => filter.key?.type === 'spanSearchScope',
);
const scopeFiltersInPayload = items.filter(isScopeFilter);
if (expectedScopeKey) {
expect(scopeFiltersInPayload).toHaveLength(1);
@@ -434,9 +452,7 @@ describe('SpanScopeSelector', () => {
items: [],
};
// Count non-scope filters
const nonScopeFilters = items.filter(
(filter) => filter.key?.type !== 'spanSearchScope',
);
const nonScopeFilters = items.filter((filter) => !isScopeFilter(filter));
expect(nonScopeFilters).toHaveLength(1);
expect(nonScopeFilters).toContainEqual(

View File

@@ -0,0 +1,12 @@
import { screen } from 'tests/test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import CreateRoleModal from './CreateRoleModal';
describe('CreateRoleModal — no-auth mode', () => {
it('renders no-auth guard sentinel for Create Role button', () => {
renderWithNoAuth(<CreateRoleModal isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('no-auth-save-role')).toBeInTheDocument();
});
});

View File

@@ -18,6 +18,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { handleApiError } from 'utils/errorUtils';
@@ -148,16 +149,17 @@ function CreateRoleModal({
<X size={14} />
Cancel
</Button>,
<Button
key="submit"
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>,
<NoAuthGuard key="submit" testId="no-auth-save-role">
<Button
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>
</NoAuthGuard>,
]}
destroyOnClose
className="create-role-modal"

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { NoAuthGuard } from 'components/NoAuthGuard';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -42,15 +43,17 @@ function RolesSettings(): JSX.Element {
/>
{isRolesEnabled && (
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
<NoAuthGuard testId="no-auth-create-custom-role">
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</NoAuthGuard>
</AuthZTooltip>
)}
</div>

View File

@@ -0,0 +1,37 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import { screen } from 'tests/test-utils';
import RolesSettings from '../RolesSettings';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const ROLES_ENDPOINT = '*/api/v1/roles';
describe('RolesSettings — no-auth mode', () => {
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
rest.get(ROLES_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
it('renders the no-auth sentinel for the Custom role button', async () => {
renderWithNoAuth(<RolesSettings />);
await screen.findByText('signoz-admin');
expect(screen.getByTestId('no-auth-create-custom-role')).toBeInTheDocument();
});
});

View File

@@ -101,6 +101,8 @@ export function getAppContextMockState(
userPreferences: null,
hostsData: null,
isLoggedIn: false,
isNoAuthMode: false,
isPreflightLoading: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -37,6 +37,7 @@ import {
} from './utils';
import './ServiceAccountsSettings.styles.scss';
import { NoAuthGuard } from 'components/NoAuthGuard';
function ServiceAccountsSettings(): JSX.Element {
const [currentPage, setPage] = useQueryState(
@@ -264,16 +265,18 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
<NoAuthGuard testId="no-auth-new-service-account">
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</NoAuthGuard>
</AuthZTooltip>
</div>

View File

@@ -0,0 +1,70 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
import { screen } from 'tests/test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockServiceAccountsAPI = [
{
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: 1700000000,
updatedAt: 1700000001,
},
];
describe('ServiceAccountsSettings — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
setupAuthzAdmin(),
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
),
rest.get(SA_ENDPOINT, (req, res, ctx) => {
const { id } = req.params as { id: string };
const account = mockServiceAccountsAPI.find((a) => a.id === id);
return account
? res(ctx.status(200), ctx.json({ data: account }))
: res(ctx.status(404), ctx.json({ message: 'Not found' }));
}),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the no-auth sentinel for the New Service Account button', async () => {
renderWithNoAuth(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);
await screen.findByText('CI Bot');
expect(screen.getByTestId('no-auth-new-service-account')).toBeInTheDocument();
});
});

View File

@@ -1120,6 +1120,7 @@
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -134,6 +134,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
featureFlags,
trialInfo,
isLoggedIn,
isNoAuthMode,
userPreferences,
changelog,
toggleChangelogModal,
@@ -408,7 +409,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
);
const handleReorderShortcutNavItems = (): void => {
logEvent('Sidebar V2: Save shortcuts clicked', {
void logEvent('Sidebar V2: Save shortcuts clicked', {
shortcuts: tempPinnedMenuItems.map((item) => item.key),
});
setPinnedMenuItems(tempPinnedMenuItems);
@@ -436,7 +437,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
const onClickGetStarted = (event: MouseEvent): void => {
logEvent('Sidebar: Menu clicked', {
void logEvent('Sidebar: Menu clicked', {
menuRoute: '/get-started',
menuLabel: 'Get Started',
});
@@ -489,12 +490,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}),
[
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
user.email,
isWorkspaceBlocked,
isNoAuthMode,
],
);
@@ -651,7 +654,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else if (item) {
onClickHandler(item?.key as string, event);
}
logEvent('Sidebar V2: Menu clicked', {
void logEvent('Sidebar V2: Menu clicked', {
menuRoute: item?.key,
menuLabel: item?.label,
});
@@ -794,7 +797,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
onTogglePin={
allowPin
? (item): void => {
logEvent(
void logEvent(
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
{
menuRoute: item.key,
@@ -841,7 +844,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
if (item && !('type' in item)) {
logEvent('Help Popover: Item clicked', {
void logEvent('Help Popover: Item clicked', {
menuRoute: item.key,
menuLabel: String(item.label),
});
@@ -890,7 +893,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
menuLabel = item.label;
}
logEvent('Settings Popover: Item clicked', {
void logEvent('Settings Popover: Item clicked', {
menuRoute: item?.key,
menuLabel,
});
@@ -927,7 +930,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
}
break;
case 'logout':
Logout();
void Logout();
break;
default:
}
@@ -1081,7 +1084,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
@@ -1128,7 +1131,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return;
}
const newCollapsedState = !isMoreMenuCollapsed;
logEvent('Sidebar V2: More menu clicked', {
void logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(newCollapsedState);
@@ -1234,14 +1237,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
open={isReorderShortcutNavItemsModalOpen}
closable
onCancel={(): void => {
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
footer={[
<Button
key="cancel"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
className="periscope-btn cancel-btn secondary-btn"

View File

@@ -5,6 +5,7 @@ const BASE_PARAMS = {
isWorkspaceBlocked: false,
isEnterpriseSelfHostedUser: false,
isCommunityEnterpriseUser: false,
isNoAuthMode: false,
};
describe('getUserSettingsDropdownMenuItems', () => {
@@ -71,4 +72,15 @@ describe('getUserSettingsDropdownMenuItems', () => {
expect(keys[3]).toBe('account');
expect(keys[keys.length - 1]).toBe('logout');
});
it('omits sign out and its preceding divider when isNoAuthMode=true', () => {
const items =
getUserSettingsDropdownMenuItems({ ...BASE_PARAMS, isNoAuthMode: true }) ??
[];
const keys = items.map((item: any) => item.key ?? item.type);
expect(keys).not.toContain('logout');
// the trailing divider before logout should also be gone
expect(keys[keys.length - 1]).toBe('keyboard-shortcuts');
});
});

View File

@@ -1,3 +1,5 @@
import { MenuProps } from 'antd';
import ROUTES from 'constants/routes';
import {
ArrowUpRight,
BarChart,
@@ -35,15 +37,13 @@ import {
Users,
Binoculars,
} from '@signozhq/icons';
import { Style } from '@signozhq/design-tokens';
import { MenuProps } from 'antd';
import ROUTES from 'constants/routes';
import {
SecondaryMenuItemKey,
SettingsNavSection,
SidebarItem,
} from './sideNav.types';
import { Style } from '@signozhq/design-tokens';
export const getStartedMenuItem = {
key: ROUTES.GET_STARTED,
@@ -487,6 +487,7 @@ export interface UserSettingsMenuItemsParams {
isWorkspaceBlocked: boolean;
isEnterpriseSelfHostedUser: boolean;
isCommunityEnterpriseUser: boolean;
isNoAuthMode: boolean;
}
export const getUserSettingsDropdownMenuItems = ({
@@ -494,6 +495,7 @@ export const getUserSettingsDropdownMenuItems = ({
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
[
{
@@ -537,21 +539,25 @@ export const getUserSettingsDropdownMenuItems = ({
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
dataTestId: 'keyboard-shortcuts-nav-item',
},
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (
<LogOut
size={14}
className="user-settings-dropdown-logout-section"
color={Style.DANGER_BACKGROUND}
/>
),
dataTestId: 'logout-nav-item',
},
...(isNoAuthMode
? []
: [
{ type: 'divider' as const },
{
key: 'logout',
label: (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (
<LogOut
size={14}
className="user-settings-dropdown-logout-section"
color={Style.DANGER_BACKGROUND}
/>
),
dataTestId: 'logout-nav-item',
},
]),
].filter(Boolean);
/** Mapping of some newly added routes and their corresponding active sidebar menu key */

View File

@@ -5,8 +5,8 @@ const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
updatedAt: new Date('2026-02-03T18:00:55.624356Z'),
createdAt: '2026-02-03T18:00:55.624356Z',
updatedAt: '2026-02-03T18:00:55.624356Z',
name: 'signoz-admin',
description:
'Role assigned to users who have full administrative access to SigNoz resources.',
@@ -15,8 +15,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
},
{
id: '019c24aa-2248-757c-9faf-7b1e899751e0',
createdAt: new Date('2026-02-03T18:00:55.624359Z'),
updatedAt: new Date('2026-02-03T18:00:55.624359Z'),
createdAt: '2026-02-03T18:00:55.624359Z',
updatedAt: '2026-02-03T18:00:55.624359Z',
name: 'signoz-editor',
description:
'Role assigned to users who can create, edit, and manage SigNoz resources but do not have full administrative privileges.',
@@ -25,8 +25,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
},
{
id: '019c24aa-2248-7585-a129-4188b3473c27',
createdAt: new Date('2026-02-03T18:00:55.624362Z'),
updatedAt: new Date('2026-02-03T18:00:55.624362Z'),
createdAt: '2026-02-03T18:00:55.624362Z',
updatedAt: '2026-02-03T18:00:55.624362Z',
name: 'signoz-viewer',
description:
'Role assigned to users who have read-only access to SigNoz resources.',
@@ -38,8 +38,8 @@ export const managedRoles: AuthtypesRoleDTO[] = [
export const customRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: new Date('2026-02-10T10:30:00.000Z'),
updatedAt: new Date('2026-02-12T14:20:00.000Z'),
createdAt: '2026-02-10T10:30:00.000Z',
updatedAt: '2026-02-12T14:20:00.000Z',
name: 'billing-manager',
description: 'Custom role for managing billing and invoices.',
type: 'custom',
@@ -47,8 +47,8 @@ export const customRoles: AuthtypesRoleDTO[] = [
},
{
id: '019c24aa-3333-0002-bbbb-222222222222',
createdAt: new Date('2026-02-11T09:00:00.000Z'),
updatedAt: new Date('2026-02-13T11:45:00.000Z'),
createdAt: '2026-02-11T09:00:00.000Z',
updatedAt: '2026-02-13T11:45:00.000Z',
name: 'dashboard-creator',
description: 'Custom role allowing users to create and manage dashboards.',
type: 'custom',

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