Compare commits

..

20 Commits

Author SHA1 Message Date
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 failed
build-staging / prepare (push) Has been cancelled
build-staging / staging (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
* 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
102 changed files with 2652 additions and 1377 deletions

View File

@@ -45,20 +45,9 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR: /tmp/signoz-e2e-buildx-cache
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: restore-buildx-cache
uses: actions/cache@v4
with:
path: /tmp/signoz-e2e-buildx-cache
key: ${{ runner.os }}-signoz-e2e-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-signoz-e2e-buildx-
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -69,20 +69,9 @@ jobs:
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
env:
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR: /tmp/signoz-integration-buildx-cache
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: restore-buildx-cache
uses: actions/cache@v4
with:
path: /tmp/signoz-integration-buildx-cache
key: ${{ runner.os }}-signoz-integration-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-signoz-integration-buildx-
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -34,7 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
##############################################################
# directories
@@ -230,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
@echo ">> python cache cleaned"
.PHONY: py-docker-clean
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
@echo ">> removing SigNoz integration test image"
@docker image rm -f signoz:integration 2>/dev/null || true
@echo ">> removing local integration buildx cache directories"
@rm -rf /tmp/signoz-integration-buildx-cache /tmp/signoz-integration-buildx-cache-next /tmp/signoz-e2e-buildx-cache /tmp/signoz-e2e-buildx-cache-next
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
.PHONY: py-test-clean
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
@$(MAKE) py-test-teardown || true
@$(MAKE) py-clean
@$(MAKE) py-docker-clean
##############################################################
# generate commands

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1
FROM golang:1.25-bookworm
ARG OS="linux"
@@ -23,8 +21,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
RUN chmod 755 /root /root/signoz

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS build
WORKDIR /opt/
@@ -32,8 +30,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

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

@@ -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,7 +127,7 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string,null
*/
@@ -1834,7 +1834,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -1851,7 +1851,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
@@ -2009,7 +2009,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2034,7 +2034,7 @@ export interface AuthtypesRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
@@ -2062,7 +2062,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt: Date;
createdAt: string;
/**
* @type string
*/
@@ -2076,7 +2076,7 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
updatedAt: Date;
updatedAt: string;
/**
* @type string
*/
@@ -2088,7 +2088,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2117,7 +2117,7 @@ export interface AuthtypesUserWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type array,null
*/
@@ -2284,7 +2284,7 @@ export interface CloudintegrationtypesAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2305,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 {
@@ -2441,7 +2441,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2451,7 +2451,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
};
/**
@@ -2645,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 {
@@ -2885,7 +2885,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -2907,7 +2907,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -3089,7 +3089,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
created_at?: Date;
created_at?: string;
/**
* @type string
*/
@@ -3111,7 +3111,7 @@ export interface GatewaytypesLimitDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
}
export interface GatewaytypesIngestionKeyDTO {
@@ -3119,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
*/
@@ -3145,7 +3145,7 @@ export interface GatewaytypesIngestionKeyDTO {
* @type string
* @format date-time
*/
updated_at?: Date;
updated_at?: string;
/**
* @type string
*/
@@ -3169,7 +3169,7 @@ export interface GatewaytypesPostableIngestionKeyDTO {
* @type string
* @format date-time
*/
expires_at?: Date;
expires_at?: string;
/**
* @type string
*/
@@ -4439,7 +4439,7 @@ export interface LlmpricingruletypesLLMPricingRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -4478,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
*/
@@ -5710,7 +5710,7 @@ export interface Querybuildertypesv5RawRowDTO {
* @type string
* @format date-time
*/
timestamp?: Date;
timestamp?: string;
}
export interface Querybuildertypesv5RawDataDTO {
@@ -6179,7 +6179,7 @@ export interface RuletypesRecurrenceDTO {
* @type string,null
* @format date-time
*/
endTime?: Date | null;
endTime?: string | null;
/**
* @type array,null
*/
@@ -6189,7 +6189,7 @@ export interface RuletypesRecurrenceDTO {
* @type string
* @format date-time
*/
startTime: Date;
startTime: string;
}
export interface RuletypesScheduleDTO {
@@ -6197,13 +6197,13 @@ export interface RuletypesScheduleDTO {
* @type string
* @format date-time
*/
endTime?: Date;
endTime?: string;
recurrence?: RuletypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: Date;
startTime?: string;
/**
* @type string
*/
@@ -6219,7 +6219,7 @@ export interface RuletypesPlannedMaintenanceDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6243,7 +6243,7 @@ export interface RuletypesPlannedMaintenanceDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6406,7 +6406,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6455,7 +6455,7 @@ export interface RuletypesRuleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6474,7 +6474,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type integer
* @minimum 0
@@ -6488,7 +6488,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
lastObservedAt: Date;
lastObservedAt: string;
/**
* @type string
*/
@@ -6501,7 +6501,7 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
@@ -6546,7 +6546,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6571,7 +6571,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountRoleDTO {
@@ -6579,7 +6579,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6597,7 +6597,7 @@ export interface ServiceaccounttypesServiceAccountRoleDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
@@ -6605,7 +6605,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6634,7 +6634,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
@@ -6676,7 +6676,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6701,7 +6701,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -6770,7 +6770,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -6796,7 +6796,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
/**
* @type string
*/
@@ -7163,7 +7163,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7196,7 +7196,7 @@ export interface TypesDeprecatedUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesIdentifiableDTO {
@@ -7211,7 +7211,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7244,7 +7244,7 @@ export interface TypesInviteDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesOrganizationDTO {
@@ -7256,7 +7256,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7278,7 +7278,7 @@ export interface TypesOrganizationDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface TypesPostableInviteDTO {
@@ -7345,7 +7345,7 @@ export interface TypesResetPasswordTokenDTO {
* @type string
* @format date-time
*/
expiresAt?: Date;
expiresAt?: string;
/**
* @type string
*/
@@ -7372,7 +7372,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt?: string;
/**
* @type string
*/
@@ -7401,7 +7401,7 @@ export interface TypesUserDTO {
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt?: string;
}
export interface ZeustypesHostDTO {

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>
<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>
<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>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</NoAuthGuard>
</div>
</>
)}

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>
<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>
<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>
<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>
<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>
<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>
<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>
<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

@@ -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

@@ -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>
<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

@@ -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>
<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>
<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">
<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,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>
<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

@@ -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>
<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

@@ -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>
<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

@@ -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>
<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>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
</NoAuthGuard>
);
}

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>
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
</NoAuthGuard>
<NoAuthGuard>
<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>
<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">
<Button
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>
</NoAuthGuard>,
]}
>
<p className="delete-text">

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,17 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
ns: 'common',
})}
</Button>,
<Button
key={t('invite_team_members').toString()}
onClick={modalForm.submit}
data-testid="invite-team-members-button"
type="primary"
disabled={isInvitingMembers}
loading={isInvitingMembers}
>
{t('invite_team_members')}
</Button>,
<NoAuthGuard key={t('invite_team_members').toString()}>
<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

@@ -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<RuletypesPlannedMaintenanceDTO & { 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

@@ -46,8 +46,6 @@ import { AlertRuleTags } from './PlannedDowntimeList';
import {
getAlertOptionsFromIds,
getDurationInfo,
getEndTime,
handleTimeConversion,
isScheduleRecurring,
recurrenceOptions,
recurrenceOptionWithSubmenu,
@@ -64,11 +62,19 @@ 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?: RuletypesRecurrenceDTO | null;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
recurrence?: RuletypesRecurrenceDTO;
alertRules: DefaultOptionType[];
recurrenceSelect?: RuletypesRecurrenceDTO;
timezone?: string;
@@ -77,11 +83,7 @@ interface PlannedDowntimeFormData {
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: Partial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>;
initialValues: Partial<RuletypesPlannedMaintenanceDTO>;
alertOptions: DefaultOptionType[];
isError: boolean;
isLoading: boolean;
@@ -89,7 +91,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance<any>;
form: FormInstance;
}
export function PlannedDowntimeForm(
@@ -107,66 +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: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
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 RuletypesRecurrenceDTO,
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone: values.timezone!,
recurrence: values.recurrence,
},
};
@@ -198,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 RuletypesRecurrenceDTO | 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(() => {
@@ -249,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'] = () => <></>;
@@ -267,113 +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 RuletypesRecurrenceDTO['repeatType'],
duration: String(
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.value ?? '',
),
: schedule?.recurrence?.repeatType,
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as RuletypesRecurrenceDTO,
timezone: initialValues.schedule?.timezone as string,
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) {
@@ -388,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
@@ -446,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}
@@ -485,7 +372,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Repeats every"
name={['recurrence', 'repeatType']}
rules={formValidationRules}
rules={requiredFieldRule}
>
<Select
placeholder="Select option..."
@@ -496,7 +383,7 @@ export function PlannedDowntimeForm(
<Form.Item
label="Weekly occurernce"
name={['recurrence', 'repeatOn']}
rules={formValidationRules}
rules={requiredFieldRule}
>
<Select
placeholder="Select option..."
@@ -510,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>
@@ -533,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"
@@ -546,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}
@@ -584,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 =>

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';
@@ -8,7 +8,7 @@ import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
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?: RuletypesRecurrenceDTO | 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 ? (
@@ -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<RuletypesPlannedMaintenanceDTO>);
? 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 RuletypesRecurrenceDTO | null | undefined
}
schedule={schedule}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
timezone={defaultTo(schedule?.timezone, '')}
/>
</Panel>
</Collapse>

View File

@@ -11,8 +11,8 @@ import type {
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs 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 = (
@@ -61,6 +67,7 @@ export const getAlertOptionsFromIds = (
export const recurrenceInfo = (
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<
RuletypesPlannedMaintenanceDTO & { 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<
RuletypesPlannedMaintenanceDTO & {
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?: 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

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

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

@@ -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">
<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>
<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

@@ -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>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</NoAuthGuard>
</AuthZTooltip>
</div>

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',

View File

@@ -1,9 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import ConversationView from 'container/AIAssistant/ConversationView';
import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
import { Sparkles } from '@signozhq/icons';
@@ -17,8 +20,27 @@ interface RouteParams {
export default function AIAssistantPage(): JSX.Element {
const history = useHistory();
const location = useLocation<{ fromInApp?: boolean } | undefined>();
const { pathname } = location;
const { conversationId } = useParams<RouteParams>();
// Skip the mount-time Opened fire when the user expanded an already-open
// drawer/modal — that surface already emitted Opened with the right source.
// Router state (vs a module flag) survives StrictMode double-mount and
// aborted navigations.
const fromInApp = location.state?.fromInApp === true;
useEffect(() => {
if (fromInApp) {
return;
}
void logEvent(AIAssistantEvents.Opened, {
source: 'deeplink',
currentPage: normalizePage(pathname),
});
// Only on mount; route param changes inside the same page aren't a re-open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
@@ -71,9 +93,14 @@ export default function AIAssistantPage(): JSX.Element {
);
const handleNewConversation = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
page: normalizePage(pathname),
mode: 'full_screen',
source: 'history_list',
});
const newId = startNewConversation();
history.push(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
}, [startNewConversation, history]);
}, [startNewConversation, history, pathname]);
// Prefer the URL param, but fall back to the store's `activeConversationId`
// for the brief render after a re-key (client UUID → backend threadId), so

View File

@@ -321,7 +321,7 @@ function SettingsPage(): JSX.Element {
isDisabled={false}
showIcon={false}
onClick={(event): void => {
logEvent('Settings V2: Menu clicked', {
void logEvent('Settings V2: Menu clicked', {
menuLabel: item.label,
menuRoute: item.key,
});

View File

@@ -112,11 +112,11 @@ export function SpanHoverCard({
}
const span = spans[idx];
const previewRows: SpanPreviewRow[] = previewFields
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.name))
.map((f) => {
const value = getSpanAttribute(span, f.key);
const value = getSpanAttribute(span, f.name);
return value !== undefined && value !== ''
? { key: f.key, value: String(value) }
? { key: f.name, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRow => r !== null);

View File

@@ -10,6 +10,7 @@ import {
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import cx from 'classnames';
import FieldsSelector from 'components/FieldsSelector';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
@@ -23,12 +24,10 @@ import {
Server,
Timer,
} from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
import { useTraceStore } from '../stores/traceStore';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
@@ -226,26 +225,15 @@ function TraceDetailsHeader({
</div>
)}
{isPreviewFieldsOpen && (
<FloatingPanel
isOpen
width={350}
height={window.innerHeight - 100}
defaultPosition={{
x: window.innerWidth - 350 - 100,
y: 50,
}}
enableResizing={false}
>
<FieldsSettings
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
dataSource={DataSource.TRACES}
/>
</FloatingPanel>
)}
<FieldsSelector
isOpen={isPreviewFieldsOpen}
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
signal={DataSource.TRACES}
maxFields={10}
/>
<AnalyticsPanel
isOpen={isAnalyticsOpen}

View File

@@ -9,10 +9,7 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from '../constants';
import { useTraceStore } from '../stores/traceStore';
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
import {
mergeTelemetryFieldKeys,
toTelemetryFieldKey,
} from '../utils/previewFields';
import { mergeTelemetryFieldKeys } from '../utils/previewFields';
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
import FlamegraphCanvas from './FlamegraphCanvas';
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
@@ -60,11 +57,7 @@ function TraceFlamegraph({
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
const flamegraphSelectFields = useMemo(
() =>
mergeTelemetryFieldKeys(
COLOR_BY_FIELDS,
previewFields.map(toTelemetryFieldKey),
),
() => mergeTelemetryFieldKeys(COLOR_BY_FIELDS, previewFields),
[previewFields],
);

View File

@@ -144,14 +144,14 @@ export function useFlamegraphHover(
const buildPreviewRows = useCallback(
(span: FlamegraphSpan): SpanPreviewRowData[] =>
previewFields
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.name))
.map((field) => {
const value = getSpanAttribute(
{ resource: span.resource, attributes: span.attributes },
field.key,
field.name,
);
return value !== undefined && value !== ''
? { key: field.key, value: String(value) }
? { key: field.name, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRowData => r !== null),

View File

@@ -1,149 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import styles from './FieldsSettings.module.scss';
const MAX_FIELDS_DEFAULT = 10;
interface FieldsSettingsProps {
title: string;
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
// field metadata without a lossy conversion at add-time.
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
onClose: () => void;
dataSource: DataSource;
maxFields?: number;
}
function FieldsSettings({
title,
fields,
onFieldsChange,
onClose,
dataSource,
maxFields = MAX_FIELDS_DEFAULT,
}: FieldsSettingsProps): JSX.Element {
// Local draft state — changes here don't persist until Save
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(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: BaseAutocompleteData): void => {
if (draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.key === field.key)) {
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.key === fields[i]?.key)
),
[draftFields, fields],
);
const isAtLimit = draftFields.length >= maxFields;
return (
<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}
/>
<OtherFields
dataSource={dataSource}
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>
);
}
export default FieldsSettings;

View File

@@ -15,6 +15,7 @@ import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
import { toTelemetryFieldKey } from '../utils/previewFields';
interface MutateOptions {
onSuccess?: () => void;
@@ -37,7 +38,7 @@ interface TraceStoreState {
// --- Derived state (cached for reference stability) ---
colorByField: TelemetryFieldKey;
availableColorByOptions: ColorByOption[];
previewFields: BaseAutocompleteData[];
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
setAggregations: (
@@ -51,7 +52,7 @@ interface TraceStoreState {
// --- Public actions (called from components) ---
setColorByField: (field: TelemetryFieldKey) => void;
setPreviewFields: (next: BaseAutocompleteData[]) => void;
setPreviewFields: (next: TelemetryFieldKey[]) => void;
}
/**
@@ -105,21 +106,31 @@ function deriveColorState(
}
/**
* Reads preview fields from user preferences and filters out malformed entries.
* Reads preview fields from user preferences and normalizes them to
* `TelemetryFieldKey`. Legacy entries persisted as `BaseAutocompleteData` (with
* a `.key` instead of `.name`) are upgraded in-place so existing users don't
* lose their saved preview-field selection.
*/
function derivePreviewFields(
userPreferences: UserPreference[] | null,
): BaseAutocompleteData[] {
): TelemetryFieldKey[] {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
);
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
return raw.filter(
(f): f is BaseAutocompleteData =>
typeof f === 'object' &&
f !== null &&
typeof (f as { key?: unknown }).key === 'string',
);
const raw = (pref?.value as unknown[] | undefined) ?? [];
const result: TelemetryFieldKey[] = [];
for (const entry of raw) {
if (typeof entry !== 'object' || entry === null) {
continue;
}
const candidate = entry as { name?: unknown; key?: unknown };
if (typeof candidate.name === 'string') {
result.push(entry as TelemetryFieldKey);
} else if (typeof candidate.key === 'string') {
result.push(toTelemetryFieldKey(entry as BaseAutocompleteData));
}
}
return result;
}
export const useTraceStore = create<TraceStoreState>()((set, get) => ({

View File

@@ -41,8 +41,9 @@ function mapFieldDataType(
}
/**
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
* for `selectFields` on the flamegraph request.
* Upgrades a legacy `BaseAutocompleteData`-shaped preview field (persisted by
* pre-migration clients) to the current `TelemetryFieldKey` shape. Kept around
* for the read-side compatibility shim in `traceStore.derivePreviewFields`.
*/
export function toTelemetryFieldKey(
field: BaseAutocompleteData,
@@ -51,6 +52,7 @@ export function toTelemetryFieldKey(
name: field.key,
fieldContext: mapFieldContext(field.type),
fieldDataType: mapFieldDataType(field.dataType),
isIndexed: field.isIndexed,
};
}

View File

@@ -13,8 +13,11 @@ import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetHosts } from 'api/generated/services/zeus';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import { clearAuthStorage } from 'utils/clearAuthStorage';
import { setNoAuthMode } from 'utils/noAuthMode';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -70,11 +73,51 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
(): boolean => getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true',
);
const [isNoAuthMode, setIsNoAuthMode] = useState<boolean>(false);
const [isPreflightLoading, setIsPreflightLoading] = useState<boolean>(true);
const [org, setOrg] = useState<Organization[] | null>(null);
const [changelog, setChangelog] = useState<ChangelogSchema | null>(null);
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// Pre-flight: discover auth mode from public global config.
// On success: in impersonation mode → clear stale tokens, force isLoggedIn=true,
// set noAuthMode singleton so the axios interceptor (outside React)
// can skip the rotate-logout chain.
// On failure: fail-safe to normal auth flow (treat as not no-auth).
const { data: globalConfigData, isLoading: isFetchingGlobalConfig } =
useGetGlobalConfig({
query: {
retry: 2,
retryDelay: 1000,
refetchOnWindowFocus: false,
staleTime: Infinity,
},
});
useEffect(() => {
if (isFetchingGlobalConfig) {
return;
}
const impersonationEnabled =
globalConfigData?.data?.identN?.impersonation?.enabled === true;
if (impersonationEnabled) {
clearAuthStorage();
setDefaultUser(getUserDefaults());
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
setNoAuthMode(true);
setIsNoAuthMode(true);
setIsLoggedIn(true);
} else {
setNoAuthMode(false);
setIsNoAuthMode(false);
}
setIsPreflightLoading(false);
}, [globalConfigData, isFetchingGlobalConfig]);
// fetcher for current user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
@@ -366,6 +409,9 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// global event listener for LOGOUT event to clean the app context state
useGlobalEventListener('LOGOUT', () => {
if (isNoAuthMode) {
return;
} // logout is meaningless in no-auth; defensively no-op
setIsLoggedIn(false);
setDefaultUser(getUserDefaults());
setActiveLicense(null);
@@ -385,6 +431,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
orgPreferences,
hostsData,
isLoggedIn,
isNoAuthMode,
isPreflightLoading,
org,
isFetchingUser,
isFetchingActiveLicense,
@@ -425,6 +473,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isLoggedIn,
hostsData,
hostsFetchError,
isNoAuthMode,
isPreflightLoading,
org,
orgPreferences,
activeLicenseRefetch,

View File

@@ -2,6 +2,7 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { getIsNoAuthMode, setNoAuthMode } from 'utils/noAuthMode';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
import { server } from 'mocks-server/server';
@@ -13,6 +14,7 @@ import { AppProvider, useAppContext } from '../App';
const MY_USER_URL = 'http://localhost/api/v2/users/me';
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
const GLOBAL_CONFIG_URL = 'http://localhost/api/v1/global/config';
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
@@ -336,3 +338,127 @@ describe('AppProvider when authz/check fails', () => {
);
});
});
describe('AppProvider no-auth preflight', () => {
beforeEach(() => {
queryClient.clear();
});
afterEach(() => {
setNoAuthMode(false);
});
it('sets isNoAuthMode=true and noAuthMode singleton when impersonation is enabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: true } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isNoAuthMode).toBe(true);
},
{ timeout: 3000 },
);
expect(getIsNoAuthMode()).toBe(true);
});
it('leaves isNoAuthMode=false and clears noAuthMode singleton when impersonation is disabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
expect(result.current.isNoAuthMode).toBe(false);
expect(getIsNoAuthMode()).toBe(false);
});
it('clears stale auth tokens from localStorage and resets in-memory JWT state when impersonation is enabled', async () => {
setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, 'stale-access-token');
setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'stale-refresh-token');
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'Old Name');
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: true } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isNoAuthMode).toBe(true);
},
{ timeout: 3000 },
);
// localStorage cleared
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
// in-memory JWTs reset so stale tokens don't linger in context or React Query keys
expect(result.current.user.accessJwt).toBe('');
expect(result.current.user.refreshJwt).toBe('');
});
it('transitions isPreflightLoading from true to false once preflight resolves', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
expect(result.current.isPreflightLoading).toBe(true);
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
});
});

View File

@@ -20,6 +20,8 @@ export interface IAppContext {
userPreferences: UserPreference[] | null;
hostsData: GetHosts200 | null;
isLoggedIn: boolean;
isNoAuthMode: boolean;
isPreflightLoading: boolean;
org: Organization[] | null;
isFetchingUser: boolean;
isFetchingActiveLicense: boolean;

View File

@@ -243,6 +243,8 @@ export function getAppContextMock(
isFetchingOrgPreferences: false,
orgPreferencesFetchError: null,
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
showChangelogModal: false,
updateUser: jest.fn(),
updateOrg: jest.fn(),

View File

@@ -0,0 +1,39 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { clearAuthStorage } from '../clearAuthStorage';
describe('clearAuthStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('removes all auth-related localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
localStorage.setItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'refresh');
localStorage.setItem(LOCALSTORAGE.IS_LOGGED_IN, 'true');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'old');
localStorage.setItem(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true');
localStorage.setItem(LOCALSTORAGE.USER_ID, 'abc');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_LOGGED_IN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_IDENTIFIED_USER)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.USER_ID)).toBeNull();
});
it('preserves non-auth localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.THEME, 'dark');
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.THEME)).toBe('dark');
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
import { LOCALSTORAGE } from 'constants/localStorage';
const AUTH_KEYS: LOCALSTORAGE[] = [
LOCALSTORAGE.AUTH_TOKEN,
LOCALSTORAGE.REFRESH_AUTH_TOKEN,
LOCALSTORAGE.IS_LOGGED_IN,
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
LOCALSTORAGE.LOGGED_IN_USER_NAME,
LOCALSTORAGE.IS_IDENTIFIED_USER,
LOCALSTORAGE.USER_ID,
];
export const clearAuthStorage = (): void => {
AUTH_KEYS.forEach((key) => deleteLocalStorageKey(key));
};

View File

@@ -0,0 +1,7 @@
let _isNoAuthMode = false;
export const setNoAuthMode = (value: boolean): void => {
_isNoAuthMode = value;
};
export const getIsNoAuthMode = (): boolean => _isNoAuthMode;

View File

@@ -203,6 +203,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateMetaresourcesTuplesFactory(sqlstore),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddRoleCRUDTuplesFactory(sqlstore),
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
)
}

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