diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000000..de2de959dabc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,83 @@ +name: "Update PR labels and Block PR until related docs are shipped for the feature" + +on: + pull_request: + branches: + - develop + types: [opened, edited, labeled, unlabeled] + +permissions: + pull-requests: write + contents: read + +jobs: + docs_label_check: + runs-on: ubuntu-latest + steps: + - name: Check PR Title and Manage Labels + uses: actions/github-script@v6 + with: + script: | + const prTitle = context.payload.pull_request.title; + const prNumber = context.payload.pull_request.number; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Fetch the current PR details to get labels + const pr = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + + const labels = pr.data.labels.map(label => label.name); + + if (prTitle.startsWith('feat:')) { + const hasDocsRequired = labels.includes('docs required'); + const hasDocsShipped = labels.includes('docs shipped'); + const hasDocsNotRequired = labels.includes('docs not required'); + + // If "docs not required" is present, skip the checks + if (hasDocsNotRequired && !hasDocsRequired) { + console.log("Skipping checks due to 'docs not required' label."); + return; // Exit the script early + } + + // If "docs shipped" is present, remove "docs required" if it exists + if (hasDocsShipped && hasDocsRequired) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: 'docs required' + }); + console.log("Removed 'docs required' label."); + } + + // Add "docs required" label if neither "docs shipped" nor "docs required" are present + if (!hasDocsRequired && !hasDocsShipped) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: ['docs required'] + }); + console.log("Added 'docs required' label."); + } + } + + // Fetch the updated labels after any changes + const updatedPr = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + + const updatedLabels = updatedPr.data.labels.map(label => label.name); + const updatedHasDocsRequired = updatedLabels.includes('docs required'); + const updatedHasDocsShipped = updatedLabels.includes('docs shipped'); + + // Block PR if "docs required" is still present and "docs shipped" is missing + if (updatedHasDocsRequired && !updatedHasDocsShipped) { + core.setFailed("This PR requires documentation. Please remove the 'docs required' label and add the 'docs shipped' label to proceed."); + } diff --git a/.github/workflows/staging-deployment.yaml b/.github/workflows/staging-deployment.yaml index 4918cf54d241..bbdbe32531a3 100644 --- a/.github/workflows/staging-deployment.yaml +++ b/.github/workflows/staging-deployment.yaml @@ -31,7 +31,6 @@ jobs: GCP_ZONE: ${{ secrets.GCP_ZONE }} GCP_INSTANCE: ${{ secrets.GCP_INSTANCE }} CLOUDSDK_CORE_DISABLE_PROMPTS: 1 - KAFKA_SPAN_EVAL: true run: | read -r -d '' COMMAND <(true); + + const { + org, + orgPreferences, + user, + role, + isUserFetching, + isUserFetchingError, + isLoggedIn: isLoggedInState, + isFetchingOrgPreferences, + } = useSelector((state) => state.app); + const mapRoutes = useMemo( () => new Map( @@ -44,18 +61,21 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { [pathname], ); + const isOnboardingComplete = useMemo( + () => + orgPreferences?.find( + (preference: Record) => preference.key === 'ORG_ONBOARDING', + )?.value, + [orgPreferences], + ); + const { data: licensesData, isFetching: isFetchingLicensesData, } = useLicense(); - const { - isUserFetching, - isUserFetchingError, - isLoggedIn: isLoggedInState, - } = useSelector((state) => state.app); - const { t } = useTranslation(['common']); + const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); const dispatch = useDispatch>(); @@ -66,6 +86,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const isOldRoute = oldRoutes.indexOf(pathname) > -1; + const [orgData, setOrgData] = useState(undefined); + const isLocalStorageLoggedIn = getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true'; @@ -81,6 +103,63 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } }; + const { data: orgUsers, isLoading: isLoadingOrgUsers } = useQuery({ + queryFn: () => { + if (orgData && orgData.id !== undefined) { + return getOrgUser({ + orgId: orgData.id, + }); + } + return undefined; + }, + queryKey: ['getOrgUser'], + enabled: !isEmpty(orgData), + }); + + const checkFirstTimeUser = (): boolean => { + const users = orgUsers?.payload || []; + + const remainingUsers = users.filter( + (user) => user.email !== 'admin@signoz.cloud', + ); + + return remainingUsers.length === 1; + }; + + // Check if the onboarding should be shown based on the org users and onboarding completion status, wait for org users and preferences to load + const shouldShowOnboarding = (): boolean => { + // Only run this effect if the org users and preferences are loaded + + if (!isLoadingOrgUsers && !isFetchingOrgPreferences) { + const isFirstUser = checkFirstTimeUser(); + + // Redirect to get started if it's not the first user or if the onboarding is complete + return isFirstUser && !isOnboardingComplete; + } + + return false; + }; + + const handleRedirectForOrgOnboarding = (key: string): void => { + if ( + isLoggedInState && + !isFetchingOrgPreferences && + !isLoadingOrgUsers && + !isEmpty(orgUsers?.payload) && + !isNull(orgPreferences) + ) { + if (key === 'ONBOARDING' && isOnboardingComplete) { + history.push(ROUTES.APPLICATION); + } + + const isFirstTimeUser = checkFirstTimeUser(); + + if (isFirstTimeUser && !isOnboardingComplete) { + history.push(ROUTES.ONBOARDING); + } + } + }; + const handleUserLoginIfTokenPresent = async ( key: keyof typeof ROUTES, ): Promise => { @@ -102,6 +181,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { response.payload.refreshJwt, ); + handleRedirectForOrgOnboarding(key); + if ( userResponse && route && @@ -129,7 +210,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { ) { handleUserLoginIfTokenPresent(key); } else { - // user does have localstorage values + handleRedirectForOrgOnboarding(key); navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn); } @@ -160,6 +241,45 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } }, [isFetchingLicensesData]); + useEffect(() => { + if (org && org.length > 0 && org[0].id !== undefined) { + setOrgData(org[0]); + } + }, [org]); + + const handleRouting = (): void => { + const showOrgOnboarding = shouldShowOnboarding(); + + if (showOrgOnboarding && !isOnboardingComplete) { + history.push(ROUTES.ONBOARDING); + } else { + history.push(ROUTES.APPLICATION); + } + }; + + useEffect(() => { + const { isPrivate } = currentRoute || { + isPrivate: false, + }; + + if (isLoggedInState && role && role !== 'ADMIN') { + setIsLoading(false); + } + + if (!isPrivate) { + setIsLoading(false); + } + + if ( + !isEmpty(user) && + !isFetchingOrgPreferences && + !isEmpty(orgUsers?.payload) && + !isNull(orgPreferences) + ) { + setIsLoading(false); + } + }, [currentRoute, user, role, orgUsers, orgPreferences]); + // eslint-disable-next-line sonarjs/cognitive-complexity useEffect(() => { (async (): Promise => { @@ -181,9 +301,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { handlePrivateRoutes(key); } else { // no need to fetch the user and make user fetching false - if (getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true') { - history.push(ROUTES.APPLICATION); + handleRouting(); } dispatch({ type: UPDATE_USER_IS_FETCH, @@ -195,7 +314,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } else if (pathname === ROUTES.HOME_PAGE) { // routing to application page over root page if (isLoggedInState) { - history.push(ROUTES.APPLICATION); + handleRouting(); } else { navigateToLoginIfNotLoggedIn(); } @@ -208,13 +327,20 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { history.push(ROUTES.SOMETHING_WENT_WRONG); } })(); - }, [dispatch, isLoggedInState, currentRoute, licensesData]); + }, [ + dispatch, + isLoggedInState, + currentRoute, + licensesData, + orgUsers, + orgPreferences, + ]); if (isUserFetchingError) { return ; } - if (isUserFetching) { + if (isUserFetching || isLoading) { return ; } diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 8cb2bf0a8b3a..c4cab694135c 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -2,6 +2,7 @@ import { ConfigProvider } from 'antd'; import getLocalStorageApi from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; import logEvent from 'api/common/logEvent'; +import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences'; import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; import { FeatureKeys } from 'constants/features'; @@ -24,13 +25,20 @@ import AlertRuleProvider from 'providers/Alert'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { Route, Router, Switch } from 'react-router-dom'; +import { CompatRouter } from 'react-router-dom-v5-compat'; import { Dispatch } from 'redux'; import { AppState } from 'store/reducers'; import AppActions from 'types/actions'; -import { UPDATE_FEATURE_FLAG_RESPONSE } from 'types/actions/app'; +import { + UPDATE_FEATURE_FLAG_RESPONSE, + UPDATE_IS_FETCHING_ORG_PREFERENCES, + UPDATE_ORG_PREFERENCES, +} from 'types/actions/app'; import AppReducer, { User } from 'types/reducer/app'; +import { USER_ROLES } from 'types/roles'; import { extractDomain, isCloudUser, isEECloudUser } from 'utils/app'; import PrivateRoute from './Private'; @@ -65,6 +73,41 @@ function App(): JSX.Element { const isPremiumSupportEnabled = useFeatureFlags(FeatureKeys.PREMIUM_SUPPORT)?.active || false; + const { data: orgPreferences, isLoading: isLoadingOrgPreferences } = useQuery({ + queryFn: () => getAllOrgPreferences(), + queryKey: ['getOrgPreferences'], + enabled: isLoggedInState && role === USER_ROLES.ADMIN, + }); + + useEffect(() => { + if (orgPreferences && !isLoadingOrgPreferences) { + dispatch({ + type: UPDATE_IS_FETCHING_ORG_PREFERENCES, + payload: { + isFetchingOrgPreferences: false, + }, + }); + + dispatch({ + type: UPDATE_ORG_PREFERENCES, + payload: { + orgPreferences: orgPreferences.payload?.data || null, + }, + }); + } + }, [orgPreferences, dispatch, isLoadingOrgPreferences]); + + useEffect(() => { + if (isLoggedInState && role !== USER_ROLES.ADMIN) { + dispatch({ + type: UPDATE_IS_FETCHING_ORG_PREFERENCES, + payload: { + isFetchingOrgPreferences: false, + }, + }); + } + }, [isLoggedInState, role, dispatch]); + const featureResponse = useGetFeatureFlag((allFlags) => { dispatch({ type: UPDATE_FEATURE_FLAG_RESPONSE, @@ -182,6 +225,16 @@ function App(): JSX.Element { }, [isLoggedInState, isOnBasicPlan, user]); useEffect(() => { + if (pathname === ROUTES.ONBOARDING) { + window.Intercom('update', { + hide_default_launcher: true, + }); + } else { + window.Intercom('update', { + hide_default_launcher: false, + }); + } + trackPageView(pathname); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]); @@ -204,6 +257,7 @@ function App(): JSX.Element { user, licenseData, isPremiumSupportEnabled, + pathname, ]); useEffect(() => { @@ -239,36 +293,38 @@ function App(): JSX.Element { return ( - - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} + + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} - - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 0a7764149b68..2def2ac11f6c 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -66,6 +66,10 @@ export const Onboarding = Loadable( () => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'), ); +export const OrgOnboarding = Loadable( + () => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'), +); + export const DashboardPage = Loadable( () => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardsListPage'), diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 42ce00c0fb07..ccbb700387b9 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -32,6 +32,7 @@ import { OldLogsExplorer, Onboarding, OrganizationSettings, + OrgOnboarding, PasswordReset, PipelinePage, ServiceMapPage, @@ -68,6 +69,13 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'GET_STARTED', }, + { + path: ROUTES.ONBOARDING, + exact: false, + component: OrgOnboarding, + isPrivate: true, + key: 'ONBOARDING', + }, { component: LogsIndexToFields, path: ROUTES.LOGS_INDEX_FIELDS, diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index 613ed27a173c..abd7d701a4c8 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -4,6 +4,7 @@ export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; export const apiV4 = '/api/v4/'; export const gatewayApiV1 = '/api/gateway/v1/'; +export const gatewayApiV2 = '/api/gateway/v2/'; export const apiAlertManager = '/api/alertmanager/'; export default apiV1; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7f5e2d476cd4..b3a810e2effd 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -15,6 +15,7 @@ import apiV1, { apiV3, apiV4, gatewayApiV1, + gatewayApiV2, } from './apiV1'; import { Logout } from './utils'; @@ -169,6 +170,19 @@ GatewayApiV1Instance.interceptors.response.use( GatewayApiV1Instance.interceptors.request.use(interceptorsRequestResponse); // +// gateway Api V2 +export const GatewayApiV2Instance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${gatewayApiV2}`, +}); + +GatewayApiV2Instance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); + +GatewayApiV2Instance.interceptors.request.use(interceptorsRequestResponse); +// + AxiosAlertManagerInstance.interceptors.response.use( interceptorsResponse, interceptorRejected, diff --git a/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts b/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts new file mode 100644 index 000000000000..da82e7013434 --- /dev/null +++ b/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts @@ -0,0 +1,39 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +export interface OnboardingStatusResponse { + status: string; + data: { + attribute?: string; + error_message?: string; + status?: string; + }[]; +} + +const getOnboardingStatus = async (props: { + start: number; + end: number; + endpointService?: string; +}): Promise | ErrorResponse> => { + const { endpointService, ...rest } = props; + try { + const response = await ApiBaseInstance.post( + `/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`, + rest, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler((error as AxiosError) || SOMETHING_WENT_WRONG); + } +}; + +export default getOnboardingStatus; diff --git a/frontend/src/api/onboarding/updateProfile.ts b/frontend/src/api/onboarding/updateProfile.ts new file mode 100644 index 000000000000..a9e2d4465314 --- /dev/null +++ b/frontend/src/api/onboarding/updateProfile.ts @@ -0,0 +1,20 @@ +import { GatewayApiV2Instance } from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { UpdateProfileProps } from 'types/api/onboarding/types'; + +const updateProfile = async ( + props: UpdateProfileProps, +): Promise | ErrorResponse> => { + const response = await GatewayApiV2Instance.put('/profiles/me', { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default updateProfile; diff --git a/frontend/src/api/preferences/updateOrgPreference.ts b/frontend/src/api/preferences/updateOrgPreference.ts index 76e5a68640af..aae4d83ddf0e 100644 --- a/frontend/src/api/preferences/updateOrgPreference.ts +++ b/frontend/src/api/preferences/updateOrgPreference.ts @@ -10,9 +10,12 @@ const updateOrgPreference = async ( ): Promise< SuccessResponse | ErrorResponse > => { - const response = await axios.put(`/org/preferences`, { - preference_value: preferencePayload.value, - }); + const response = await axios.put( + `/org/preferences/${preferencePayload.preferenceID}`, + { + preference_value: preferencePayload.value, + }, + ); return { statusCode: 200, diff --git a/frontend/src/api/user/inviteUsers.ts b/frontend/src/api/user/inviteUsers.ts new file mode 100644 index 000000000000..d7afb7ff5351 --- /dev/null +++ b/frontend/src/api/user/inviteUsers.ts @@ -0,0 +1,18 @@ +import axios from 'api'; +import { SuccessResponse } from 'types/api'; +import { InviteUsersResponse, UsersProps } from 'types/api/user/inviteUsers'; + +const inviteUsers = async ( + users: UsersProps, +): Promise> => { + const response = await axios.post(`/invite/bulk`, users); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; +}; + +export default inviteUsers; diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.test.ts b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts index 17c601ffb4ae..f940ee304607 100644 --- a/frontend/src/components/Logs/LogStateIndicator/utils.test.ts +++ b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts @@ -17,6 +17,7 @@ describe('getLogIndicatorType', () => { body: 'Sample log Message', resources_string: {}, attributesString: {}, + scope_string: {}, attributes_string: {}, attributesInt: {}, attributesFloat: {}, @@ -40,6 +41,7 @@ describe('getLogIndicatorType', () => { body: 'Sample log Message', resources_string: {}, attributesString: {}, + scope_string: {}, attributes_string: {}, attributesInt: {}, attributesFloat: {}, @@ -62,6 +64,7 @@ describe('getLogIndicatorType', () => { body: 'Sample log Message', resources_string: {}, attributesString: {}, + scope_string: {}, attributes_string: {}, attributesInt: {}, attributesFloat: {}, @@ -83,6 +86,7 @@ describe('getLogIndicatorType', () => { body: 'Sample log', resources_string: {}, attributesString: {}, + scope_string: {}, attributes_string: { log_level: 'INFO' as never, }, @@ -112,6 +116,7 @@ describe('getLogIndicatorTypeForTable', () => { attributesString: {}, attributes_string: {}, attributesInt: {}, + scope_string: {}, attributesFloat: {}, severity_text: 'WARN', }; @@ -130,6 +135,7 @@ describe('getLogIndicatorTypeForTable', () => { severity_number: 0, body: 'Sample log message', resources_string: {}, + scope_string: {}, attributesString: {}, attributes_string: {}, attributesInt: {}, @@ -166,6 +172,7 @@ describe('logIndicatorBySeverityNumber', () => { body: 'Sample log Message', resources_string: {}, attributesString: {}, + scope_string: {}, attributes_string: {}, attributesInt: {}, attributesFloat: {}, diff --git a/frontend/src/constants/query.ts b/frontend/src/constants/query.ts index 2214e9487c79..56fbd737b5ee 100644 --- a/frontend/src/constants/query.ts +++ b/frontend/src/constants/query.ts @@ -37,4 +37,8 @@ export enum QueryParams { partition = 'partition', selectedTimelineQuery = 'selectedTimelineQuery', ruleType = 'ruleType', + configDetail = 'configDetail', + getStartedSource = 'getStartedSource', + getStartedSourceService = 'getStartedSourceService', + mqServiceView = 'mqServiceView', } diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index b4f43ee68467..3c17336889ba 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -8,6 +8,7 @@ const ROUTES = { TRACE_DETAIL: '/trace/:id', TRACES_EXPLORER: '/traces-explorer', GET_STARTED: '/get-started', + ONBOARDING: '/onboarding', GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring', GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management', GET_STARTED_INFRASTRUCTURE_MONITORING: diff --git a/frontend/src/container/AppLayout/AppLayout.styles.scss b/frontend/src/container/AppLayout/AppLayout.styles.scss index a991f08351df..98ca9084f2c2 100644 --- a/frontend/src/container/AppLayout/AppLayout.styles.scss +++ b/frontend/src/container/AppLayout/AppLayout.styles.scss @@ -7,6 +7,8 @@ width: calc(100% - 64px); z-index: 0; + margin: 0 auto; + .content-container { position: relative; margin: 0 1rem; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index beb4cea61c52..d9beb2d5d3ac 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -191,6 +191,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const pageTitle = t(routeKey); const renderFullScreen = pathname === ROUTES.GET_STARTED || + pathname === ROUTES.ONBOARDING || pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING || pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING || pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT || diff --git a/frontend/src/container/CreateAlertRule/defaults.ts b/frontend/src/container/CreateAlertRule/defaults.ts index 44dee01d3112..34058a06f6ce 100644 --- a/frontend/src/container/CreateAlertRule/defaults.ts +++ b/frontend/src/container/CreateAlertRule/defaults.ts @@ -94,6 +94,7 @@ export const anamolyAlertDefaults: AlertDef = { matchType: defaultMatchType, algorithm: defaultAlgorithm, seasonality: defaultSeasonality, + target: 3, }, labels: { severity: 'warning', diff --git a/frontend/src/container/FormAlertRules/RuleOptions.tsx b/frontend/src/container/FormAlertRules/RuleOptions.tsx index 969e34b9589b..e9aa8f860f07 100644 --- a/frontend/src/container/FormAlertRules/RuleOptions.tsx +++ b/frontend/src/container/FormAlertRules/RuleOptions.tsx @@ -386,32 +386,31 @@ function RuleOptions({ renderThresholdRuleOpts()} - {queryCategory !== EQueryType.PROM && - ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && ( - - - e.currentTarget.blur()} - /> - + {ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && ( + + + e.currentTarget.blur()} + /> + - - + + + )} diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 9b33198ff714..05c4149d73ad 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -73,6 +73,19 @@ export enum AlertDetectionTypes { ANOMALY_DETECTION_ALERT = 'anomaly_rule', } +const ALERT_SETUP_GUIDE_URLS: Record = { + [AlertTypes.METRICS_BASED_ALERT]: + 'https://signoz.io/docs/alerts-management/metrics-based-alerts/?utm_source=product&utm_medium=alert-creation-page', + [AlertTypes.LOGS_BASED_ALERT]: + 'https://signoz.io/docs/alerts-management/log-based-alerts/?utm_source=product&utm_medium=alert-creation-page', + [AlertTypes.TRACES_BASED_ALERT]: + 'https://signoz.io/docs/alerts-management/trace-based-alerts/?utm_source=product&utm_medium=alert-creation-page', + [AlertTypes.EXCEPTIONS_BASED_ALERT]: + 'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page', + [AlertTypes.ANOMALY_BASED_ALERT]: + 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page', +}; + // eslint-disable-next-line sonarjs/cognitive-complexity function FormAlertRules({ alertType, @@ -702,6 +715,29 @@ function FormAlertRules({ const isRuleCreated = !ruleId || ruleId === 0; + function handleRedirection(option: AlertTypes): void { + let url; + if ( + option === AlertTypes.METRICS_BASED_ALERT && + alertTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT + ) { + url = ALERT_SETUP_GUIDE_URLS[AlertTypes.ANOMALY_BASED_ALERT]; + } else { + url = ALERT_SETUP_GUIDE_URLS[option]; + } + + if (url) { + logEvent('Alert: Check example alert clicked', { + dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes], + isNewRule: !ruleId || ruleId === 0, + ruleId, + queryType: currentQuery.queryType, + link: url, + }); + window.open(url, '_blank'); + } + } + useEffect(() => { if (!isRuleCreated) { logEvent('Alert: Edit page visited', { @@ -752,7 +788,11 @@ function FormAlertRules({ )} - diff --git a/frontend/src/container/FormAlertRules/labels/index.tsx b/frontend/src/container/FormAlertRules/labels/index.tsx index 30583e12f962..250c48be8aa0 100644 --- a/frontend/src/container/FormAlertRules/labels/index.tsx +++ b/frontend/src/container/FormAlertRules/labels/index.tsx @@ -138,6 +138,9 @@ function LabelSelect({ if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') { send('NEXT'); } + if (state.value === 'Idle') { + send('NEXT'); + } }} bordered={false} value={currentVal as never} diff --git a/frontend/src/container/LogDetailedView/utils.tsx b/frontend/src/container/LogDetailedView/utils.tsx index 766bb8b5bf96..da62f97f8e2b 100644 --- a/frontend/src/container/LogDetailedView/utils.tsx +++ b/frontend/src/container/LogDetailedView/utils.tsx @@ -157,6 +157,11 @@ export const getFieldAttributes = (field: string): IFieldAttributes => { const stringWithoutPrefix = field.slice('resources_'.length); const parts = splitOnce(stringWithoutPrefix, '.'); [dataType, newField] = parts; + } else if (field.startsWith('scope_string')) { + logType = MetricsType.Scope; + const stringWithoutPrefix = field.slice('scope_'.length); + const parts = splitOnce(stringWithoutPrefix, '.'); + [dataType, newField] = parts; } return { dataType, newField, logType }; @@ -187,6 +192,7 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => { traceId: logData.traceId, attributes: {}, resources: {}, + scope: {}, severity_text: logData.severity_text, severity_number: logData.severity_number, }; @@ -198,6 +204,9 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => { } else if (key.startsWith('resources_')) { outputJson.resources = outputJson.resources || {}; Object.assign(outputJson.resources, logData[key as keyof ILog]); + } else if (key.startsWith('scope_string')) { + outputJson.scope = outputJson.scope || {}; + Object.assign(outputJson.scope, logData[key as keyof ILog]); } else { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/frontend/src/container/MetricsApplication/constant.ts b/frontend/src/container/MetricsApplication/constant.ts index 9c039292bd0c..decd31534b16 100644 --- a/frontend/src/container/MetricsApplication/constant.ts +++ b/frontend/src/container/MetricsApplication/constant.ts @@ -53,6 +53,7 @@ export enum KeyOperationTableHeader { export enum MetricsType { Tag = 'tag', Resource = 'resource', + Scope = 'scope', } export enum WidgetKeys { diff --git a/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-consumers.md b/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-consumers.md new file mode 100644 index 000000000000..1c521bcba420 --- /dev/null +++ b/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-consumers.md @@ -0,0 +1,32 @@ +  + +Once you are done instrumenting your Java application, you can run it using the below commands + +**Note:** +- Ensure you have Java and Maven installed. Compile your Java consumer applications: Ensure your consumer apps are compiled and ready to run. + +**Run Consumer App with Java Agent:** + +```bash +java -javaagent:/path/to/opentelemetry-javaagent.jar \ + -Dotel.service.name=consumer-svc \ + -Dotel.traces.exporter=otlp \ + -Dotel.metrics.exporter=otlp \ + -Dotel.logs.exporter=otlp \ + -Dotel.instrumentation.kafka.producer-propagation.enabled=true \ + -Dotel.instrumentation.kafka.experimental-span-attributes=true \ + -Dotel.instrumentation.kafka.metric-reporter.enabled=true \ + -jar /path/to/your/consumer.jar +``` + + - update it to the path where you downloaded the Java JAR agent in previous step + - Jar file of your application + +  + +**Note:** +- In case you're dockerising your application, make sure to dockerise it along with OpenTelemetry instrumentation done in previous step. + +  + +If you encounter any difficulties, please consult the [troubleshooting section](https://signoz.io/docs/instrumentation/springboot/#troubleshooting-your-installation) for assistance. \ No newline at end of file diff --git a/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-producers.md b/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-producers.md new file mode 100644 index 000000000000..01a11a9ad77e --- /dev/null +++ b/frontend/src/container/OnboardingContainer/Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-producers.md @@ -0,0 +1,29 @@ +  + +Once you are done instrumenting your Java application, you can run it using the below commands + +**Note:** +- Ensure you have Java and Maven installed. Compile your Java producer applications: Ensure your producer apps are compiled and ready to run. + +**Run Producer App with Java Agent:** + +```bash +java -javaagent:/path/to/opentelemetry-javaagent.jar \ + -Dotel.service.name=producer-svc \ + -Dotel.traces.exporter=otlp \ + -Dotel.metrics.exporter=otlp \ + -Dotel.logs.exporter=otlp \ + -jar /path/to/your/producer.jar +``` + + - update it to the path where you downloaded the Java JAR agent in previous step + - Jar file of your application + +  + +**Note:** +- In case you're dockerising your application, make sure to dockerise it along with OpenTelemetry instrumentation done in previous step. + +  + +If you encounter any difficulties, please consult the [troubleshooting section](https://signoz.io/docs/instrumentation/springboot/#troubleshooting-your-installation) for assistance. \ No newline at end of file diff --git a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx index c1275ff115b5..861786f2aa16 100644 --- a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx +++ b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx @@ -312,7 +312,7 @@ export default function Onboarding(): JSX.Element {
{ logEvent('Onboarding V2: Skip Button Clicked', {}); - history.push('/'); + history.push(ROUTES.APPLICATION); }} className="skip-to-console" > diff --git a/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx b/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx index 4cbdb39414b3..0fc81c053329 100644 --- a/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx +++ b/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx @@ -6,11 +6,16 @@ import { LoadingOutlined, } from '@ant-design/icons'; import logEvent from 'api/common/logEvent'; +import { QueryParams } from 'constants/query'; import Header from 'container/OnboardingContainer/common/Header/Header'; import { useOnboardingContext } from 'container/OnboardingContainer/context/OnboardingContext'; +import { useOnboardingStatus } from 'hooks/messagingQueue / onboarding/useOnboardingStatus'; import { useQueryService } from 'hooks/useQueryService'; import useResourceAttribute from 'hooks/useResourceAttribute'; import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; +import useUrlQuery from 'hooks/useUrlQuery'; +import MessagingQueueHealthCheck from 'pages/MessagingQueues/MessagingQueueHealthCheck/MessagingQueueHealthCheck'; +import { getAttributeDataFromOnboardingStatus } from 'pages/MessagingQueues/MessagingQueuesUtils'; import { useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -27,6 +32,12 @@ export default function ConnectionStatus(): JSX.Element { GlobalReducer >((state) => state.globalTime); + const urlQuery = useUrlQuery(); + const getStartedSource = urlQuery.get(QueryParams.getStartedSource); + const getStartedSourceService = urlQuery.get( + QueryParams.getStartedSourceService, + ); + const { serviceName, selectedDataSource, @@ -57,8 +68,69 @@ export default function ConnectionStatus(): JSX.Element { maxTime, selectedTime, selectedTags, + options: { + enabled: getStartedSource !== 'kafka', + }, }); + const [pollInterval, setPollInterval] = useState(10000); + const { + data: onbData, + error: onbErr, + isFetching: onbFetching, + } = useOnboardingStatus( + { + enabled: getStartedSource === 'kafka', + refetchInterval: pollInterval, + }, + getStartedSourceService || '', + 'query-key-onboarding-status', + ); + + const [ + shouldRetryOnboardingCall, + setShouldRetryOnboardingCall, + ] = useState(false); + + useEffect(() => { + // runs only when the caller is coming from 'kafka' i.e. coming from Messaging Queues - setup helper + if (getStartedSource === 'kafka') { + if (onbData?.statusCode !== 200) { + setShouldRetryOnboardingCall(true); + } else if (onbData?.payload?.status === 'success') { + const attributeData = getAttributeDataFromOnboardingStatus( + onbData?.payload, + ); + if (attributeData.overallStatus === 'success') { + setLoading(false); + setIsReceivingData(true); + setPollInterval(false); + } else { + setShouldRetryOnboardingCall(true); + } + } + } + }, [ + shouldRetryOnboardingCall, + onbData, + onbErr, + onbFetching, + getStartedSource, + ]); + + useEffect(() => { + if (retryCount < 0 && getStartedSource === 'kafka') { + setPollInterval(false); + setLoading(false); + } + }, [retryCount, getStartedSource]); + + useEffect(() => { + if (getStartedSource === 'kafka' && !onbFetching) { + setRetryCount((prevCount) => prevCount - 1); + } + }, [getStartedSource, onbData, onbFetching]); + const renderDocsReference = (): JSX.Element => { switch (selectedDataSource?.name) { case 'java': @@ -192,25 +264,27 @@ export default function ConnectionStatus(): JSX.Element { useEffect(() => { let pollingTimer: string | number | NodeJS.Timer | undefined; - if (loading) { - pollingTimer = setInterval(() => { - // Trigger a refetch with the updated parameters - const updatedMinTime = (Date.now() - 15 * 60 * 1000) * 1000000; - const updatedMaxTime = Date.now() * 1000000; + if (getStartedSource !== 'kafka') { + if (loading) { + pollingTimer = setInterval(() => { + // Trigger a refetch with the updated parameters + const updatedMinTime = (Date.now() - 15 * 60 * 1000) * 1000000; + const updatedMaxTime = Date.now() * 1000000; - const payload = { - maxTime: updatedMaxTime, - minTime: updatedMinTime, - selectedTime, - }; + const payload = { + maxTime: updatedMaxTime, + minTime: updatedMinTime, + selectedTime, + }; - dispatch({ - type: UPDATE_TIME_INTERVAL, - payload, - }); - }, pollingInterval); // Same interval as pollingInterval - } else if (!loading && pollingTimer) { - clearInterval(pollingTimer); + dispatch({ + type: UPDATE_TIME_INTERVAL, + payload, + }); + }, pollingInterval); // Same interval as pollingInterval + } else if (!loading && pollingTimer) { + clearInterval(pollingTimer); + } } // Clean up the interval when the component unmounts @@ -221,15 +295,24 @@ export default function ConnectionStatus(): JSX.Element { }, [refetch, selectedTags, selectedTime, loading]); useEffect(() => { - verifyApplicationData(data); + if (getStartedSource !== 'kafka') { + verifyApplicationData(data); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isServiceLoading, data, error, isError]); useEffect(() => { - refetch(); + if (getStartedSource !== 'kafka') { + refetch(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const isQueryServiceLoading = useMemo( + () => isServiceLoading || loading || onbFetching, + [isServiceLoading, loading, onbFetching], + ); + return (
{renderDocsReference()}
@@ -250,30 +333,42 @@ export default function ConnectionStatus(): JSX.Element {
Status
- {(loading || isServiceLoading) && } - {!(loading || isServiceLoading) && isReceivingData && ( - <> - - Success - - )} - {!(loading || isServiceLoading) && !isReceivingData && ( - <> - - Failed - - )} + {isQueryServiceLoading && } + {!isQueryServiceLoading && + isReceivingData && + (getStartedSource !== 'kafka' ? ( + <> + + Success + + ) : ( + + ))} + {!isQueryServiceLoading && + !isReceivingData && + (getStartedSource !== 'kafka' ? ( + <> + + Failed + + ) : ( + + ))}
Details
- {(loading || isServiceLoading) &&
Waiting for Update
} - {!(loading || isServiceLoading) && isReceivingData && ( + {isQueryServiceLoading &&
Waiting for Update
} + {!isQueryServiceLoading && isReceivingData && (
Received data from the application successfully.
)} - {!(loading || isServiceLoading) && !isReceivingData && ( + {!isQueryServiceLoading && !isReceivingData && (
Could not detect the install
)}
diff --git a/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.styles.scss b/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.styles.scss index 2bbd18bab24b..6b6a032a6b28 100644 --- a/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.styles.scss +++ b/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.styles.scss @@ -74,4 +74,11 @@ div[class*='-setup-instructions-container'] { .dataSourceName { color: var(--bg-slate-500); } -} \ No newline at end of file +} + +.supported-languages-container { + .disabled { + cursor: not-allowed; + opacity: 0.5; + } +} diff --git a/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.tsx b/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.tsx index f2f7028bdcb5..0936c4754dc2 100644 --- a/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.tsx +++ b/frontend/src/container/OnboardingContainer/Steps/DataSource/DataSource.tsx @@ -6,15 +6,21 @@ import { LoadingOutlined } from '@ant-design/icons'; import { Button, Card, Form, Input, Select, Space, Typography } from 'antd'; import logEvent from 'api/common/logEvent'; import cx from 'classnames'; +import { QueryParams } from 'constants/query'; import ROUTES from 'constants/routes'; import { useOnboardingContext } from 'container/OnboardingContainer/context/OnboardingContext'; -import { useCases } from 'container/OnboardingContainer/OnboardingContainer'; +import { + ModulesMap, + useCases, +} from 'container/OnboardingContainer/OnboardingContainer'; import { getDataSources, getSupportedFrameworks, hasFrameworks, + messagingQueueKakfaSupportedDataSources, } from 'container/OnboardingContainer/utils/dataSourceUtils'; import { useNotifications } from 'hooks/useNotifications'; +import useUrlQuery from 'hooks/useUrlQuery'; import { Blocks, Check } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,6 +39,8 @@ export default function DataSource(): JSX.Element { const { t } = useTranslation(['common']); const history = useHistory(); + const getStartedSource = useUrlQuery().get(QueryParams.getStartedSource); + const { serviceName, selectedModule, @@ -44,6 +52,9 @@ export default function DataSource(): JSX.Element { updateSelectedFramework, } = useOnboardingContext(); + const isKafkaAPM = + getStartedSource === 'kafka' && selectedModule?.id === ModulesMap.APM; + const [supportedDataSources, setSupportedDataSources] = useState< DataSourceType[] >([]); @@ -150,13 +161,19 @@ export default function DataSource(): JSX.Element { className={cx( 'supported-language', selectedDataSource?.name === dataSource.name ? 'selected' : '', + isKafkaAPM && + !messagingQueueKakfaSupportedDataSources.includes(dataSource?.id || '') + ? 'disabled' + : '', )} key={dataSource.name} onClick={(): void => { - updateSelectedFramework(null); - updateSelectedEnvironment(null); - updateSelectedDataSource(dataSource); - form.setFieldsValue({ selectFramework: null }); + if (!isKafkaAPM) { + updateSelectedFramework(null); + updateSelectedEnvironment(null); + updateSelectedDataSource(dataSource); + form.setFieldsValue({ selectFramework: null }); + } }} >
diff --git a/frontend/src/container/OnboardingContainer/Steps/MarkdownStep/MarkdownStep.tsx b/frontend/src/container/OnboardingContainer/Steps/MarkdownStep/MarkdownStep.tsx index 695471434268..c6b6e9dbb565 100644 --- a/frontend/src/container/OnboardingContainer/Steps/MarkdownStep/MarkdownStep.tsx +++ b/frontend/src/container/OnboardingContainer/Steps/MarkdownStep/MarkdownStep.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer'; +import { QueryParams } from 'constants/query'; import { ApmDocFilePaths } from 'container/OnboardingContainer/constants/apmDocFilePaths'; import { AwsMonitoringDocFilePaths } from 'container/OnboardingContainer/constants/awsMonitoringDocFilePaths'; import { AzureMonitoringDocFilePaths } from 'container/OnboardingContainer/constants/azureMonitoringDocFilePaths'; @@ -10,6 +11,7 @@ import { useOnboardingContext, } from 'container/OnboardingContainer/context/OnboardingContext'; import { ModulesMap } from 'container/OnboardingContainer/OnboardingContainer'; +import useUrlQuery from 'hooks/useUrlQuery'; import { useEffect, useState } from 'react'; export interface IngestionInfoProps { @@ -31,6 +33,12 @@ export default function MarkdownStep(): JSX.Element { const [markdownContent, setMarkdownContent] = useState(''); + const urlQuery = useUrlQuery(); + const getStartedSource = urlQuery.get(QueryParams.getStartedSource); + const getStartedSourceService = urlQuery.get( + QueryParams.getStartedSourceService, + ); + const { step } = activeStep; const getFilePath = (): any => { @@ -54,6 +62,12 @@ export default function MarkdownStep(): JSX.Element { path += `_${step?.id}`; + if ( + getStartedSource === 'kafka' && + path === 'APM_java_springBoot_kubernetes_recommendedSteps_runApplication' // todo: Sagar - Make this generic logic in followup PRs + ) { + path += `_${getStartedSourceService}`; + } return path; }; diff --git a/frontend/src/container/OnboardingContainer/constants/apmDocFilePaths.ts b/frontend/src/container/OnboardingContainer/constants/apmDocFilePaths.ts index b80124b516e5..b91d13d3bbc5 100644 --- a/frontend/src/container/OnboardingContainer/constants/apmDocFilePaths.ts +++ b/frontend/src/container/OnboardingContainer/constants/apmDocFilePaths.ts @@ -252,6 +252,8 @@ import APM_java_springBoot_docker_recommendedSteps_runApplication from '../Modul import APM_java_springBoot_kubernetes_recommendedSteps_setupOtelCollector from '../Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-installOtelCollector.md'; import APM_java_springBoot_kubernetes_recommendedSteps_instrumentApplication from '../Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-instrumentApplication.md'; import APM_java_springBoot_kubernetes_recommendedSteps_runApplication from '../Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication.md'; +import APM_java_springBoot_kubernetes_recommendedSteps_runApplication_consumers from '../Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-consumers.md'; +import APM_java_springBoot_kubernetes_recommendedSteps_runApplication_producers from '../Modules/APM/Java/md-docs/SpringBoot/Kubernetes/springBoot-kubernetes-runApplication-producers.md'; // SpringBoot-LinuxAMD64-quickstart import APM_java_springBoot_linuxAMD64_quickStart_instrumentApplication from '../Modules/APM/Java/md-docs/SpringBoot/LinuxAMD64/QuickStart/springBoot-linuxamd64-quickStart-instrumentApplication.md'; import APM_java_springBoot_linuxAMD64_quickStart_runApplication from '../Modules/APM/Java/md-docs/SpringBoot/LinuxAMD64/QuickStart/springBoot-linuxamd64-quickStart-runApplication.md'; @@ -1053,6 +1055,8 @@ export const ApmDocFilePaths = { APM_java_springBoot_kubernetes_recommendedSteps_setupOtelCollector, APM_java_springBoot_kubernetes_recommendedSteps_instrumentApplication, APM_java_springBoot_kubernetes_recommendedSteps_runApplication, + APM_java_springBoot_kubernetes_recommendedSteps_runApplication_producers, + APM_java_springBoot_kubernetes_recommendedSteps_runApplication_consumers, // SpringBoot-LinuxAMD64-recommended APM_java_springBoot_linuxAMD64_recommendedSteps_setupOtelCollector, diff --git a/frontend/src/container/OnboardingContainer/utils/dataSourceUtils.ts b/frontend/src/container/OnboardingContainer/utils/dataSourceUtils.ts index 03f92c2a399e..450f17b35ab2 100644 --- a/frontend/src/container/OnboardingContainer/utils/dataSourceUtils.ts +++ b/frontend/src/container/OnboardingContainer/utils/dataSourceUtils.ts @@ -399,3 +399,5 @@ export const moduleRouteMap = { [ModulesMap.AwsMonitoring]: ROUTES.GET_STARTED_AWS_MONITORING, [ModulesMap.AzureMonitoring]: ROUTES.GET_STARTED_AZURE_MONITORING, }; + +export const messagingQueueKakfaSupportedDataSources = ['java']; diff --git a/frontend/src/container/OnboardingQuestionaire/AboutSigNozQuestions/AboutSigNozQuestions.tsx b/frontend/src/container/OnboardingQuestionaire/AboutSigNozQuestions/AboutSigNozQuestions.tsx new file mode 100644 index 000000000000..1c061803be08 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/AboutSigNozQuestions/AboutSigNozQuestions.tsx @@ -0,0 +1,235 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import '../OnboardingQuestionaire.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button, Input, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export interface SignozDetails { + hearAboutSignoz: string | null; + interestInSignoz: string | null; + otherInterestInSignoz: string | null; + otherAboutSignoz: string | null; +} + +interface AboutSigNozQuestionsProps { + signozDetails: SignozDetails; + setSignozDetails: (details: SignozDetails) => void; + onNext: () => void; + onBack: () => void; +} + +const hearAboutSignozOptions: Record = { + search: 'Google / Search', + hackerNews: 'Hacker News', + linkedin: 'LinkedIn', + twitter: 'Twitter', + reddit: 'Reddit', + colleaguesFriends: 'Colleagues / Friends', +}; + +const interestedInOptions: Record = { + savingCosts: 'Saving costs', + otelNativeStack: 'Interested in Otel-native stack', + allInOne: 'All in one (Logs, Metrics & Traces)', +}; + +export function AboutSigNozQuestions({ + signozDetails, + setSignozDetails, + onNext, + onBack, +}: AboutSigNozQuestionsProps): JSX.Element { + const [hearAboutSignoz, setHearAboutSignoz] = useState( + signozDetails?.hearAboutSignoz || null, + ); + const [otherAboutSignoz, setOtherAboutSignoz] = useState( + signozDetails?.otherAboutSignoz || '', + ); + const [interestInSignoz, setInterestInSignoz] = useState( + signozDetails?.interestInSignoz || null, + ); + const [otherInterestInSignoz, setOtherInterestInSignoz] = useState( + signozDetails?.otherInterestInSignoz || '', + ); + const [isNextDisabled, setIsNextDisabled] = useState(true); + + useEffect((): void => { + if ( + hearAboutSignoz !== null && + (hearAboutSignoz !== 'Others' || otherAboutSignoz !== '') && + interestInSignoz !== null && + (interestInSignoz !== 'Others' || otherInterestInSignoz !== '') + ) { + setIsNextDisabled(false); + } else { + setIsNextDisabled(true); + } + }, [ + hearAboutSignoz, + otherAboutSignoz, + interestInSignoz, + otherInterestInSignoz, + ]); + + const handleOnNext = (): void => { + setSignozDetails({ + hearAboutSignoz, + otherAboutSignoz, + interestInSignoz, + otherInterestInSignoz, + }); + + logEvent('Org Onboarding: Answered', { + hearAboutSignoz, + otherAboutSignoz, + interestInSignoz, + otherInterestInSignoz, + }); + + onNext(); + }; + + const handleOnBack = (): void => { + setSignozDetails({ + hearAboutSignoz, + otherAboutSignoz, + interestInSignoz, + otherInterestInSignoz, + }); + + onBack(); + }; + + return ( +
+ + Tell Us About Your Interest in SigNoz + + + We'd love to know a little bit about you and your interest in SigNoz + + +
+
+
+
Where did you hear about SigNoz?
+
+ {Object.keys(hearAboutSignozOptions).map((option: string) => ( + + ))} + + {hearAboutSignoz === 'Others' ? ( + + ) : ( + '' + ) + } + onChange={(e): void => setOtherAboutSignoz(e.target.value)} + /> + ) : ( + + )} +
+
+ +
+
What got you interested in SigNoz?
+
+ {Object.keys(interestedInOptions).map((option: string) => ( + + ))} + + {interestInSignoz === 'Others' ? ( + + ) : ( + '' + ) + } + onChange={(e): void => setOtherInterestInSignoz(e.target.value)} + /> + ) : ( + + )} +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.styles.scss b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.styles.scss new file mode 100644 index 000000000000..2f47e1d3e3dd --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.styles.scss @@ -0,0 +1,122 @@ +.team-member-container { + display: flex; + align-items: center; + + .team-member-role-select { + width: 20%; + + .ant-select-selector { + border: 1px solid #1d212d; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + } + + .team-member-email-input { + width: 80%; + background-color: #121317; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + + .ant-input, + .ant-input-group-addon { + background-color: #121317 !important; + border-right: 0px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + } + } +} + +.questions-form-container { + .error-message-container, + .success-message-container, + .partially-sent-invites-container { + border-radius: 4px; + width: 100%; + display: flex; + align-items: center; + + .error-message, + .success-message { + font-size: 12px; + font-weight: 400; + + display: flex; + align-items: center; + gap: 8px; + } + } + + .invite-users-error-message-container, + .invite-users-success-message-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + + .success-message { + color: var(--bg-success-500, #00b37e); + } + } + + .partially-sent-invites-container { + margin-top: 16px; + padding: 8px; + border: 1px solid #1d212d; + background-color: #121317; + + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + + .partially-sent-invites-message { + color: var(--bg-warning-500, #fbbd23); + + font-size: 12px; + font-weight: 400; + + display: flex; + align-items: center; + gap: 8px; + } + } +} + +.lightMode { + .team-member-container { + .team-member-role-select { + .ant-select-selector { + border: 1px solid var(--bg-vanilla-300); + } + } + + .team-member-email-input { + background-color: var(--bg-vanilla-100); + + .ant-input, + .ant-input-group-addon { + background-color: var(--bg-vanilla-100) !important; + } + } + } + + .questions-form-container { + .invite-users-error-message-container, + .invite-users-success-message-container { + .success-message { + color: var(--bg-success-500, #00b37e); + } + } + + .partially-sent-invites-container { + border: 1px solid var(--bg-vanilla-300); + background-color: var(--bg-vanilla-100); + + .partially-sent-invites-message { + color: var(--bg-warning-500, #fbbd23); + } + } + } +} diff --git a/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.tsx b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.tsx new file mode 100644 index 000000000000..def1cf979d86 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers.tsx @@ -0,0 +1,450 @@ +import './InviteTeamMembers.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button, Input, Select, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import inviteUsers from 'api/user/inviteUsers'; +import { AxiosError } from 'axios'; +import { cloneDeep, debounce, isEmpty } from 'lodash-es'; +import { + ArrowLeft, + ArrowRight, + CheckCircle, + Loader2, + Plus, + TriangleAlert, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import { SuccessResponse } from 'types/api'; +import { + FailedInvite, + InviteUsersResponse, + SuccessfulInvite, +} from 'types/api/user/inviteUsers'; +import { v4 as uuid } from 'uuid'; + +interface TeamMember { + email: string; + role: string; + name: string; + frontendBaseUrl: string; + id: string; +} + +interface InviteTeamMembersProps { + isLoading: boolean; + teamMembers: TeamMember[] | null; + setTeamMembers: (teamMembers: TeamMember[]) => void; + onNext: () => void; + onBack: () => void; +} + +function InviteTeamMembers({ + isLoading, + teamMembers, + setTeamMembers, + onNext, + onBack, +}: InviteTeamMembersProps): JSX.Element { + const [teamMembersToInvite, setTeamMembersToInvite] = useState< + TeamMember[] | null + >(teamMembers); + const [emailValidity, setEmailValidity] = useState>( + {}, + ); + const [hasInvalidEmails, setHasInvalidEmails] = useState(false); + + const [hasErrors, setHasErrors] = useState(true); + + const [error, setError] = useState(null); + + const [inviteUsersErrorResponse, setInviteUsersErrorResponse] = useState< + string[] | null + >(null); + + const [inviteUsersSuccessResponse, setInviteUsersSuccessResponse] = useState< + string[] | null + >(null); + + const [disableNextButton, setDisableNextButton] = useState(false); + + const defaultTeamMember: TeamMember = { + email: '', + role: 'EDITOR', + name: '', + frontendBaseUrl: window.location.origin, + id: '', + }; + + useEffect(() => { + if (isEmpty(teamMembers)) { + const teamMember = { + ...defaultTeamMember, + id: uuid(), + }; + + setTeamMembersToInvite([teamMember]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [teamMembers]); + + const handleAddTeamMember = (): void => { + const newTeamMember = { + ...defaultTeamMember, + id: uuid(), + }; + setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]); + }; + + const handleRemoveTeamMember = (id: string): void => { + setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id)); + }; + + // Validation function to check all users + const validateAllUsers = (): boolean => { + let isValid = true; + + const updatedValidity: Record = {}; + + teamMembersToInvite?.forEach((member) => { + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email); + if (!emailValid || !member.email) { + isValid = false; + setHasInvalidEmails(true); + } + updatedValidity[member.id!] = emailValid; + }); + + setEmailValidity(updatedValidity); + + return isValid; + }; + + const parseInviteUsersSuccessResponse = ( + response: SuccessfulInvite[], + ): string[] => response.map((invite) => `${invite.email} - Invite Sent`); + + const parseInviteUsersErrorResponse = (response: FailedInvite[]): string[] => + response.map((invite) => `${invite.email} - ${invite.error}`); + + const handleError = (error: AxiosError): void => { + const errorMessage = error.response?.data as InviteUsersResponse; + + if (errorMessage?.status === 'failure') { + setHasErrors(true); + + const failedInvitesErrorResponse = parseInviteUsersErrorResponse( + errorMessage.failed_invites, + ); + + setInviteUsersErrorResponse(failedInvitesErrorResponse); + } + }; + + const handleInviteUsersSuccess = ( + response: SuccessResponse, + ): void => { + const inviteUsersResponse = response.payload as InviteUsersResponse; + + if (inviteUsersResponse?.status === 'success') { + const successfulInvites = parseInviteUsersSuccessResponse( + inviteUsersResponse.successful_invites, + ); + + setDisableNextButton(true); + + setError(null); + setHasErrors(false); + setInviteUsersErrorResponse(null); + + setInviteUsersSuccessResponse(successfulInvites); + + logEvent('Org Onboarding: Invite Team Members Success', { + teamMembers: teamMembersToInvite, + totalInvites: inviteUsersResponse.summary.total_invites, + successfulInvites: inviteUsersResponse.summary.successful_invites, + failedInvites: inviteUsersResponse.summary.failed_invites, + }); + + setTimeout(() => { + setDisableNextButton(false); + onNext(); + }, 1000); + } else if (inviteUsersResponse?.status === 'partial_success') { + const successfulInvites = parseInviteUsersSuccessResponse( + inviteUsersResponse.successful_invites, + ); + + setInviteUsersSuccessResponse(successfulInvites); + + logEvent('Org Onboarding: Invite Team Members Partial Success', { + teamMembers: teamMembersToInvite, + totalInvites: inviteUsersResponse.summary.total_invites, + successfulInvites: inviteUsersResponse.summary.successful_invites, + failedInvites: inviteUsersResponse.summary.failed_invites, + }); + + if (inviteUsersResponse.failed_invites.length > 0) { + setHasErrors(true); + + setInviteUsersErrorResponse( + parseInviteUsersErrorResponse(inviteUsersResponse.failed_invites), + ); + } + } + }; + + const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation( + inviteUsers, + { + onSuccess: (response: SuccessResponse): void => { + handleInviteUsersSuccess(response); + }, + onError: (error: AxiosError): void => { + logEvent('Org Onboarding: Invite Team Members Failed', { + teamMembers: teamMembersToInvite, + }); + + handleError(error); + }, + }, + ); + + const handleNext = (): void => { + if (validateAllUsers()) { + setTeamMembers(teamMembersToInvite || []); + + setHasInvalidEmails(false); + setError(null); + setHasErrors(false); + setInviteUsersErrorResponse(null); + setInviteUsersSuccessResponse(null); + + sendInvites({ + users: teamMembersToInvite || [], + }); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedValidateEmail = useCallback( + debounce((email: string, memberId: string) => { + const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + setEmailValidity((prev) => ({ ...prev, [memberId]: isValid })); + }, 500), + [], + ); + + const handleEmailChange = ( + e: React.ChangeEvent, + member: TeamMember, + ): void => { + const { value } = e.target; + const updatedMembers = cloneDeep(teamMembersToInvite || []); + + const memberToUpdate = updatedMembers.find((m) => m.id === member.id); + if (memberToUpdate) { + memberToUpdate.email = value; + setTeamMembersToInvite(updatedMembers); + debouncedValidateEmail(value, member.id!); + } + }; + + const handleRoleChange = (role: string, member: TeamMember): void => { + const updatedMembers = cloneDeep(teamMembersToInvite || []); + const memberToUpdate = updatedMembers.find((m) => m.id === member.id); + if (memberToUpdate) { + memberToUpdate.role = role; + setTeamMembersToInvite(updatedMembers); + } + }; + + const handleDoLater = (): void => { + logEvent('Org Onboarding: Clicked Do Later', { + currentPageID: 4, + }); + + onNext(); + }; + + return ( +
+ + Invite your team members + + + The more your team uses SigNoz, the stronger your observability. Share + dashboards, collaborate on alerts, and troubleshoot faster together. + + +
+
+
+
+ Collaborate with your team +
+ Invite your team to the SigNoz workspace +
+
+ +
+ {teamMembersToInvite?.map((member) => ( +
+ ): void => + handleEmailChange(e, member) + } + addonAfter={ + // eslint-disable-next-line no-nested-ternary + emailValidity[member.id!] === undefined ? null : emailValidity[ + member.id! + ] ? ( + + ) : ( + + ) + } + /> + + + {teamMembersToInvite?.length > 1 && ( +
+ ))} +
+ +
+ +
+
+ + {hasInvalidEmails && ( +
+ + Please enter valid emails for all team + members + +
+ )} + + {error && ( +
+ + {error} + +
+ )} + + {hasErrors && ( + <> + {/* show only when invites are sent successfully & partial error is present */} + {inviteUsersSuccessResponse && inviteUsersErrorResponse && ( +
+ {inviteUsersSuccessResponse?.map((success, index) => ( + + {success} + + ))} +
+ )} + +
+ {inviteUsersErrorResponse?.map((error, index) => ( + + {error} + + ))} +
+ + )} +
+ + {/* Partially sent invites */} + {inviteUsersSuccessResponse && inviteUsersErrorResponse && ( +
+ + + Some invites were sent successfully. Please fix the errors above and + resend invites. + + + + You can click on I'll do this later to go to next step. + +
+ )} + +
+ + + +
+ +
+ +
+
+
+ ); +} + +export default InviteTeamMembers; diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.styles.scss b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.styles.scss new file mode 100644 index 000000000000..b91cd854f18c --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.styles.scss @@ -0,0 +1,49 @@ +.footer-main-container { + display: flex; + justify-content: center; + box-sizing: border-box; +} + +.footer-container { + display: inline-flex; + height: 36px; + padding: 12px; + justify-content: center; + align-items: center; + gap: 32px; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid var(--Greyscale-Slate-500, #161922); + background: var(--Ink-400, #121317); + + width: 100%; + max-width: 600px; +} + +.footer-container .footer-content { + display: flex; + align-items: center; + gap: 10px; +} + +.footer-container .footer-link { + display: flex; + align-items: center; + gap: 6px; + color: #c0c1c3; +} + +.footer-container .footer-text { + color: var(--Vanilla-400, var(--Greyscale-Vanilla-400, #c0c1c3)); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: 0.2px; +} + +.footer-container .footer-dot { + width: 4px; + height: 4px; + fill: var(--Greyscale-Slate-200, #2c3140); +} diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.tsx b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.tsx new file mode 100644 index 000000000000..45e55542e373 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/OnboardingFooter.tsx @@ -0,0 +1,31 @@ +import './OnboardingFooter.styles.scss'; + +import { Dot } from 'lucide-react'; + +export function OnboardingFooter(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/index.ts b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/index.ts new file mode 100644 index 000000000000..cc029cb359d9 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingFooter/index.ts @@ -0,0 +1 @@ +export { OnboardingFooter } from './OnboardingFooter'; diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.styles.scss b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.styles.scss new file mode 100644 index 000000000000..0b37c654be0f --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.styles.scss @@ -0,0 +1,65 @@ +.header-container { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0px; + + box-sizing: border-box; +} + +.header-container .logo-container { + display: flex; + align-items: center; + gap: 10px; +} + +.header-container .logo-container img { + height: 17.5px; + width: 17.5px; +} + +.header-container .logo-text { + font-family: 'Work Sans', sans-serif; + color: var(--bg-vanilla-100); + font-size: 15.4px; + font-style: normal; + font-weight: 500; + line-height: 17.5px; +} + +.header-container .get-help-container { + display: flex; + width: 113px; + height: 32px; + padding: 6px; + justify-content: center; + align-items: center; + gap: 6px; + flex-shrink: 0; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: none; +} + +.header-container .get-help-container img { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.header-container .get-help-text { + color: var(--bg-vanilla-400); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 10px; + letter-spacing: 0.12px; +} + +.lightMode { + .header-container .logo-text { + color: var(--bg-slate-300); + } +} diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.tsx b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.tsx new file mode 100644 index 000000000000..9d4df97676a3 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/OnboardingHeader.tsx @@ -0,0 +1,12 @@ +import './OnboardingHeader.styles.scss'; + +export function OnboardingHeader(): JSX.Element { + return ( +
+
+ SigNoz + SigNoz +
+
+ ); +} diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/index.ts b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/index.ts new file mode 100644 index 000000000000..644a6d9b84f7 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingHeader/index.ts @@ -0,0 +1 @@ +export { OnboardingHeader } from './OnboardingHeader'; diff --git a/frontend/src/container/OnboardingQuestionaire/OnboardingQuestionaire.styles.scss b/frontend/src/container/OnboardingQuestionaire/OnboardingQuestionaire.styles.scss new file mode 100644 index 000000000000..784a15bfebec --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OnboardingQuestionaire.styles.scss @@ -0,0 +1,597 @@ +.onboarding-questionaire-container { + width: 100%; + display: flex; + margin: 0 auto; + align-items: center; + flex-direction: column; + height: 100vh; + max-width: 1176px; + + .onboarding-questionaire-header { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + + height: 56px; + } + + .onboarding-questionaire-content { + height: calc(100vh - 56px - 60px); + width: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + + .questions-container { + color: var(--bg-vanilla-100, #fff); + font-family: Inter; + font-size: 24px; + font-style: normal; + font-weight: 600; + line-height: 32px; + max-width: 600px; + margin: 0 auto; + border-radius: 8px; + max-height: 100%; + } + + .title { + color: var(--bg-vanilla-100) !important; + font-size: 24px !important; + line-height: 32px !important; + margin-bottom: 8px !important; + } + + .sub-title { + color: var(--bg-vanilla-400) !important; + font-size: 14px !important; + font-style: normal; + font-weight: 400 !important; + line-height: 24px !important; + margin-top: 0px !important; + margin-bottom: 24px !important; + } + + .questions-form-container { + max-width: 600px; + width: 600px; + margin: 0 auto; + } + + .questions-form { + width: 100%; + display: flex; + min-height: 420px; + padding: 20px 24px 24px 24px; + flex-direction: column; + align-items: center; + gap: 24px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + + .ant-form-item { + margin-bottom: 0px !important; + + .ant-form-item-label { + label { + color: var(--bg-vanilla-100) !important; + font-size: 13px !important; + font-weight: 500; + line-height: 20px; + } + } + } + + &.invite-team-members-form { + min-height: calc(420px - 24px); + max-height: calc(420px - 24px); + + .invite-team-members-container { + max-height: 260px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 0.1rem; + } + &::-webkit-scrollbar-corner { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgb(136, 136, 136); + border-radius: 0.625rem; + } + &::-webkit-scrollbar-track { + background: transparent; + } + } + } + } + + .invite-team-members-container { + display: flex; + width: 100%; + flex-direction: column; + gap: 12px; + + .ant-input-group { + width: 100%; + + .ant-input { + font-size: 12px; + + height: 32px; + background: var(--Ink-300, #16181d); + border: 1px solid var(--bg-slate-400); + color: var(--bg-vanilla-400); + } + + .ant-input-group-addon { + font-size: 11px; + height: 32px; + min-width: 80px; + background: var(--Ink-300, #16181d); + border: 1px solid var(--Greyscale-Slate-400, #1d212d); + border-left: 0px; + color: var(--bg-vanilla-400); + } + } + } + + .question-label { + color: var(--bg-vanilla-100); + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 20px; + } + + .question-sub-label { + color: var(--bg-vanilla-400); + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .next-prev-container { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 24px; + + .ant-btn { + flex: 1; + } + } + + .form-group { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + } + + .slider-container { + width: 100%; + + .ant-slider .ant-slider-mark { + font-size: 10px; + } + } + + .do-later-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 24px; + + .do-later-button { + font-size: 12px; + + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } + } + + .question { + color: var(--bg-vanilla-100); + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + + display: flex; + align-items: center; + gap: 8px; + } + + input[type='text'] { + width: 100%; + padding: 12px; + border-radius: 2px; + font-size: 14px; + height: 40px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + color: var(--bg-vanilla-100); + + &:focus-visible { + outline: none; + } + } + + .radio-group, + .grid, + .tool-grid { + display: flex; + align-items: flex-start; + align-content: flex-start; + gap: 10px; + align-self: stretch; + flex-wrap: wrap; + } + + .radio-button, + .grid-button, + .tool-button { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + padding: 12px; + color: var(--bg-vanilla-400); + font-size: 14px; + font-style: normal; + text-align: left; + font-weight: 400; + transition: background-color 0.3s ease; + min-width: 258px; + cursor: pointer; + box-sizing: border-box; + } + + .radio-button.active, + .grid-button.active, + .tool-button.active, + .radio-button:hover, + .grid-button:hover, + .tool-button:hover { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + + .two-column-grid { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; /* Two equal columns */ + gap: 10px; + } + + .onboarding-questionaire-button, + .add-another-member-button, + .remove-team-member-button { + display: flex; + align-items: center; + justify-content: space-between; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + color: var(--bg-vanilla-400); + box-shadow: none; + font-size: 14px; + font-style: normal; + text-align: left; + font-weight: 400; + transition: background-color 0.3s ease; + cursor: pointer; + height: 40px; + box-sizing: border-box; + + &:hover { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + + &:focus-visible { + outline: none; + } + + &.active { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + } + + .add-another-member-button, + .remove-team-member-button { + font-size: 12px; + height: 32px; + } + + .remove-team-member-button { + display: flex; + align-items: center; + justify-content: center; + + border: 1px solid var(--bg-slate-400); + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + background-color: var(--bg-ink-300); + + border-left: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + + .onboarding-questionaire-other-input { + .ant-input-group { + .ant-input { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; + } + } + } + + .tool-grid { + grid-template-columns: repeat(4, 1fr); + } + + .input-field { + flex: 0; + padding: 12px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + color: var(--bg-vanilla-100); + border-radius: 4px; + font-size: 14px; + min-width: 258px; + } + + .next-button { + display: flex; + height: 40px; + padding: 8px 12px 8px 16px; + justify-content: center; + align-items: center; + gap: 6px; + align-self: stretch; + border: 0px; + border-radius: 50px; + margin-top: 24px; + cursor: pointer; + } + + .next-button.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + + .arrow { + font-size: 18px; + color: var(--bg-vanilla-100); + } + } + + .onboarding-questionaire-footer { + width: 100%; + height: 60px; + padding: 12px 24px; + box-sizing: border-box; + } + + .invite-team-members-add-another-member-container { + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + margin-top: 12px; + } +} + +.onboarding-questionaire-loading-container { + width: 100%; + display: flex; + height: 100vh; + max-width: 600px; + justify-content: center; + align-items: center; + margin: 0 auto; +} + +.lightMode { + .onboarding-questionaire-container { + .onboarding-questionaire-content { + .questions-container { + color: var(--bg-slate-300); + } + + .title { + color: var(--bg-slate-300) !important; + } + + .sub-title { + color: var(--bg-slate-400) !important; + } + + .questions-form { + width: 100%; + display: flex; + min-height: 420px; + padding: 20px 24px 24px 24px; + flex-direction: column; + align-items: center; + gap: 24px; + border-radius: 4px; + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + + .ant-form-item { + margin-bottom: 0px !important; + + .ant-form-item-label { + label { + color: var(--bg-slate-300) !important; + font-size: 13px; + font-weight: 500; + line-height: 20px; + } + } + } + + &.invite-team-members-form { + .invite-team-members-container { + max-height: 260px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 0.1rem; + } + &::-webkit-scrollbar-corner { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgb(136, 136, 136); + border-radius: 0.625rem; + } + &::-webkit-scrollbar-track { + background: transparent; + } + } + } + } + + .invite-team-members-container { + .ant-input-group { + .ant-input { + background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-slate-300); + } + + .ant-input-group-addon { + font-size: 11px; + height: 32px; + min-width: 80px; + background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + border-left: 0px; + color: var(--bg-slate-300); + } + } + } + + .question-label { + color: var(--bg-slate-300); + } + + .question-sub-label { + color: var(--bg-slate-400); + } + + .question { + color: var(--bg-slate-300); + } + + input[type='text'] { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .radio-button, + .grid-button, + .tool-button { + border-radius: 4px; + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + padding: 12px; + color: var(--bg-slate-300); + font-size: 14px; + font-style: normal; + text-align: left; + font-weight: 400; + transition: background-color 0.3s ease; + min-width: 258px; + cursor: pointer; + box-sizing: border-box; + } + + .radio-button.active, + .grid-button.active, + .tool-button.active, + .radio-button:hover, + .grid-button:hover, + .tool-button:hover { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + + .onboarding-questionaire-button, + .add-another-member-button, + .remove-team-member-button { + display: flex; + align-items: center; + justify-content: space-between; + border-radius: 2px; + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + color: var(--bg-ink-300); + box-shadow: none; + font-size: 14px; + font-style: normal; + text-align: left; + font-weight: 400; + transition: background-color 0.3s ease; + cursor: pointer; + height: 40px; + box-sizing: border-box; + + &:hover { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + + &:focus-visible { + outline: none; + } + + &.active { + border: 1px solid rgba(78, 116, 248, 0.4); + background: rgba(78, 116, 248, 0.2); + } + } + + .remove-team-member-button { + display: flex; + align-items: center; + justify-content: center; + + border: 1px solid var(--bg-vanilla-300); + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + background-color: var(--bg-vanilla-100); + + border-left: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + + .input-field { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .arrow { + color: var(--bg-slate-300); + } + } + } +} diff --git a/frontend/src/container/OnboardingQuestionaire/OptimiseSignozNeeds/OptimiseSignozNeeds.tsx b/frontend/src/container/OnboardingQuestionaire/OptimiseSignozNeeds/OptimiseSignozNeeds.tsx new file mode 100644 index 000000000000..dc499c930864 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OptimiseSignozNeeds/OptimiseSignozNeeds.tsx @@ -0,0 +1,323 @@ +import { Button, Slider, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { ArrowLeft, ArrowRight, Loader2, Minus } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export interface OptimiseSignozDetails { + logsPerDay: number; + hostsPerDay: number; + services: number; +} + +// Define exponential range +const logsMin = 1; // Set to your minimum value in the exponential range +const logsMax = 10000; // Set to your maximum value in the exponential range + +const hostsMin = 1; +const hostsMax = 10000; + +const servicesMin = 1; +const servicesMax = 5000; + +// Function to convert linear slider value to exponential scale +const linearToExponential = ( + value: number, + min: number, + max: number, +): number => { + const expMin = Math.log10(min); + const expMax = Math.log10(max); + const expValue = 10 ** (expMin + ((expMax - expMin) * value) / 100); + return Math.round(expValue); +}; + +const exponentialToLinear = ( + expValue: number, + min: number, + max: number, +): number => { + const expMin = Math.log10(min); + const expMax = Math.log10(max); + const linearValue = + ((Math.log10(expValue) - expMin) / (expMax - expMin)) * 100; + return Math.round(linearValue); // Round to get a whole number within the 0-100 range +}; + +interface OptimiseSignozNeedsProps { + optimiseSignozDetails: OptimiseSignozDetails; + setOptimiseSignozDetails: (details: OptimiseSignozDetails) => void; + onNext: () => void; + onBack: () => void; + onWillDoLater: () => void; + isUpdatingProfile: boolean; + isNextDisabled: boolean; +} + +const marks = { + 0: `${linearToExponential(0, logsMin, logsMax).toLocaleString()} GB`, + 25: `${linearToExponential(25, logsMin, logsMax).toLocaleString()} GB`, + 50: `${linearToExponential(50, logsMin, logsMax).toLocaleString()} GB`, + 75: `${linearToExponential(75, logsMin, logsMax).toLocaleString()} GB`, + 100: `${linearToExponential(100, logsMin, logsMax).toLocaleString()} GB`, +}; + +const hostMarks = { + 0: `${linearToExponential(0, hostsMin, hostsMax).toLocaleString()}`, + 25: `${linearToExponential(25, hostsMin, hostsMax).toLocaleString()}`, + 50: `${linearToExponential(50, hostsMin, hostsMax).toLocaleString()}`, + 75: `${linearToExponential(75, hostsMin, hostsMax).toLocaleString()}`, + 100: `${linearToExponential(100, hostsMin, hostsMax).toLocaleString()}`, +}; + +const serviceMarks = { + 0: `${linearToExponential(0, servicesMin, servicesMax).toLocaleString()}`, + 25: `${linearToExponential(25, servicesMin, servicesMax).toLocaleString()}`, + 50: `${linearToExponential(50, servicesMin, servicesMax).toLocaleString()}`, + 75: `${linearToExponential(75, servicesMin, servicesMax).toLocaleString()}`, + 100: `${linearToExponential(100, servicesMin, servicesMax).toLocaleString()}`, +}; + +function OptimiseSignozNeeds({ + isUpdatingProfile, + optimiseSignozDetails, + setOptimiseSignozDetails, + onNext, + onBack, + onWillDoLater, + isNextDisabled, +}: OptimiseSignozNeedsProps): JSX.Element { + const [logsPerDay, setLogsPerDay] = useState( + optimiseSignozDetails?.logsPerDay || 0, + ); + const [hostsPerDay, setHostsPerDay] = useState( + optimiseSignozDetails?.hostsPerDay || 0, + ); + const [services, setServices] = useState( + optimiseSignozDetails?.services || 0, + ); + + // Internal state for the linear slider + const [sliderValues, setSliderValues] = useState({ + logsPerDay: 0, + hostsPerDay: 0, + services: 0, + }); + + useEffect(() => { + setSliderValues({ + logsPerDay: exponentialToLinear(logsPerDay, logsMin, logsMax), + hostsPerDay: exponentialToLinear(hostsPerDay, hostsMin, hostsMax), + services: exponentialToLinear(services, servicesMin, servicesMax), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setOptimiseSignozDetails({ + logsPerDay, + hostsPerDay, + services, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [services, hostsPerDay, logsPerDay]); + + const handleOnNext = (): void => { + logEvent('Org Onboarding: Answered', { + logsPerDay, + hostsPerDay, + services, + }); + + onNext(); + }; + + const handleOnBack = (): void => { + onBack(); + }; + + const handleWillDoLater = (): void => { + setOptimiseSignozDetails({ + logsPerDay: 0, + hostsPerDay: 0, + services: 0, + }); + + onWillDoLater(); + + logEvent('Org Onboarding: Clicked Do Later', { + currentPageID: 3, + }); + }; + + const handleSliderChange = (key: string, value: number): void => { + setSliderValues({ + ...sliderValues, + [key]: value, + }); + + switch (key) { + case 'logsPerDay': + setLogsPerDay(linearToExponential(value, logsMin, logsMax)); + break; + case 'hostsPerDay': + setHostsPerDay(linearToExponential(value, hostsMin, hostsMax)); + break; + case 'services': + setServices(linearToExponential(value, servicesMin, servicesMax)); + break; + default: + break; + } + }; + + // Calculate the exponential value based on the current slider position + const logsPerDayValue = linearToExponential( + sliderValues.logsPerDay, + logsMin, + logsMax, + ); + const hostsPerDayValue = linearToExponential( + sliderValues.hostsPerDay, + hostsMin, + hostsMax, + ); + const servicesValue = linearToExponential( + sliderValues.services, + servicesMin, + servicesMax, + ); + + return ( +
+ + Optimize SigNoz for Your Needs + + + Give us a quick sense of your scale so SigNoz can keep up! + + +
+
+ + What does your scale approximately look like? + + +
+ +
+
+ + handleSliderChange('logsPerDay', value) + } + styles={{ + track: { + background: '#4E74F8', + }, + }} + tooltip={{ + formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`, // Show whole number + }} + /> +
+
+
+ +
+ +
+
+ + handleSliderChange('hostsPerDay', value) + } + styles={{ + track: { + background: '#4E74F8', + }, + }} + tooltip={{ + formatter: (): string => `${hostsPerDayValue.toLocaleString()}`, // Show whole number + }} + /> +
+
+
+ +
+ +
+
+ + handleSliderChange('services', value) + } + styles={{ + track: { + background: '#4E74F8', + }, + }} + tooltip={{ + formatter: (): string => `${servicesValue.toLocaleString()}`, // Show whole number + }} + /> +
+
+
+
+ +
+ + + +
+ +
+ +
+
+
+ ); +} + +export default OptimiseSignozNeeds; diff --git a/frontend/src/container/OnboardingQuestionaire/OrgQuestions/OrgQuestions.styles.scss b/frontend/src/container/OnboardingQuestionaire/OrgQuestions/OrgQuestions.styles.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frontend/src/container/OnboardingQuestionaire/OrgQuestions/OrgQuestions.tsx b/frontend/src/container/OnboardingQuestionaire/OrgQuestions/OrgQuestions.tsx new file mode 100644 index 000000000000..7569e0fa8172 --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/OrgQuestions/OrgQuestions.tsx @@ -0,0 +1,376 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import '../OnboardingQuestionaire.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button, Input, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import editOrg from 'api/user/editOrg'; +import { useNotifications } from 'hooks/useNotifications'; +import { ArrowRight, CheckCircle, Loader2 } from 'lucide-react'; +import { Dispatch, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { UPDATE_ORG_NAME } from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; + +export interface OrgData { + id: string; + isAnonymous: boolean; + name: string; +} + +export interface OrgDetails { + organisationName: string; + usesObservability: boolean | null; + observabilityTool: string | null; + otherTool: string | null; + familiarity: string | null; +} + +interface OrgQuestionsProps { + currentOrgData: OrgData | null; + orgDetails: OrgDetails; + onNext: (details: OrgDetails) => void; +} + +const observabilityTools = { + AWSCloudwatch: 'AWS Cloudwatch', + DataDog: 'DataDog', + NewRelic: 'New Relic', + GrafanaPrometheus: 'Grafana / Prometheus', + AzureAppMonitor: 'Azure App Monitor', + GCPNativeO11yTools: 'GCP-native o11y tools', + Honeycomb: 'Honeycomb', +}; + +const o11yFamiliarityOptions: Record = { + beginner: 'Beginner', + intermediate: 'Intermediate', + expert: 'Expert', + notFamiliar: "I'm not familiar with it", +}; + +function OrgQuestions({ + currentOrgData, + orgDetails, + onNext, +}: OrgQuestionsProps): JSX.Element { + const { user } = useSelector((state) => state.app); + const { notifications } = useNotifications(); + const dispatch = useDispatch>(); + + const { t } = useTranslation(['organizationsettings', 'common']); + + const [organisationName, setOrganisationName] = useState( + orgDetails?.organisationName || '', + ); + const [usesObservability, setUsesObservability] = useState( + orgDetails?.usesObservability || null, + ); + const [observabilityTool, setObservabilityTool] = useState( + orgDetails?.observabilityTool || null, + ); + const [otherTool, setOtherTool] = useState( + orgDetails?.otherTool || '', + ); + const [familiarity, setFamiliarity] = useState( + orgDetails?.familiarity || null, + ); + const [isNextDisabled, setIsNextDisabled] = useState(true); + + useEffect(() => { + setOrganisationName(orgDetails.organisationName); + }, [orgDetails.organisationName]); + + const [isLoading, setIsLoading] = useState(false); + + const handleOrgNameUpdate = async (): Promise => { + /* Early bailout if orgData is not set or if the organisation name is not set or if the organisation name is empty or if the organisation name is the same as the one in the orgData */ + if ( + !currentOrgData || + !organisationName || + organisationName === '' || + orgDetails.organisationName === organisationName + ) { + logEvent('Org Onboarding: Answered', { + usesObservability, + observabilityTool, + otherTool, + familiarity, + }); + + onNext({ + organisationName, + usesObservability, + observabilityTool, + otherTool, + familiarity, + }); + + return; + } + + try { + setIsLoading(true); + const { statusCode, error } = await editOrg({ + isAnonymous: currentOrgData.isAnonymous, + name: organisationName, + orgId: currentOrgData.id, + }); + if (statusCode === 200) { + dispatch({ + type: UPDATE_ORG_NAME, + payload: { + orgId: currentOrgData?.id, + name: orgDetails.organisationName, + }, + }); + + logEvent('Org Onboarding: Org Name Updated', { + organisationName: orgDetails.organisationName, + }); + + logEvent('Org Onboarding: Answered', { + usesObservability, + observabilityTool, + otherTool, + familiarity, + }); + + onNext({ + organisationName, + usesObservability, + observabilityTool, + otherTool, + familiarity, + }); + } else { + logEvent('Org Onboarding: Org Name Update Failed', { + organisationName: orgDetails.organisationName, + }); + + notifications.error({ + message: + error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + notifications.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + const isValidUsesObservability = (): boolean => { + if (usesObservability === null) { + return false; + } + + if (usesObservability && (!observabilityTool || observabilityTool === '')) { + return false; + } + + // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (usesObservability && observabilityTool === 'Others' && otherTool === '') { + return false; + } + + return true; + }; + + useEffect(() => { + const isValidObservability = isValidUsesObservability(); + + if (organisationName !== '' && familiarity !== null && isValidObservability) { + setIsNextDisabled(false); + } else { + setIsNextDisabled(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + organisationName, + usesObservability, + familiarity, + observabilityTool, + otherTool, + ]); + + const handleOnNext = (): void => { + handleOrgNameUpdate(); + }; + + return ( +
+ + Welcome, {user?.name}! + + + We'll help you get the most out of SigNoz, whether you're new to + observability or a seasoned pro. + + +
+
+
+ + setOrganisationName(e.target.value)} + /> +
+ +
+ + +
+ + +
+
+ + {usesObservability && ( +
+ +
+ {Object.keys(observabilityTools).map((tool) => ( + + ))} + + {observabilityTool === 'Others' ? ( + + ) : ( + '' + ) + } + onChange={(e): void => setOtherTool(e.target.value)} + /> + ) : ( + + )} +
+
+ )} + +
+
+ Are you familiar with setting up observability (o11y)? +
+
+ {Object.keys(o11yFamiliarityOptions).map((option: string) => ( + + ))} +
+
+
+ +
+ +
+
+
+ ); +} + +export default OrgQuestions; diff --git a/frontend/src/container/OnboardingQuestionaire/index.tsx b/frontend/src/container/OnboardingQuestionaire/index.tsx new file mode 100644 index 000000000000..390ac00212dd --- /dev/null +++ b/frontend/src/container/OnboardingQuestionaire/index.tsx @@ -0,0 +1,295 @@ +import './OnboardingQuestionaire.styles.scss'; + +import { NotificationInstance } from 'antd/es/notification/interface'; +import logEvent from 'api/common/logEvent'; +import updateProfileAPI from 'api/onboarding/updateProfile'; +import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences'; +import updateOrgPreferenceAPI from 'api/preferences/updateOrgPreference'; +import { AxiosError } from 'axios'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import ROUTES from 'constants/routes'; +import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer'; +import { useNotifications } from 'hooks/useNotifications'; +import history from 'lib/history'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { + UPDATE_IS_FETCHING_ORG_PREFERENCES, + UPDATE_ORG_PREFERENCES, +} from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; + +import { + AboutSigNozQuestions, + SignozDetails, +} from './AboutSigNozQuestions/AboutSigNozQuestions'; +import InviteTeamMembers from './InviteTeamMembers/InviteTeamMembers'; +import { OnboardingHeader } from './OnboardingHeader/OnboardingHeader'; +import OptimiseSignozNeeds, { + OptimiseSignozDetails, +} from './OptimiseSignozNeeds/OptimiseSignozNeeds'; +import OrgQuestions, { OrgData, OrgDetails } from './OrgQuestions/OrgQuestions'; + +export const showErrorNotification = ( + notifications: NotificationInstance, + err: Error, +): void => { + notifications.error({ + message: err.message || SOMETHING_WENT_WRONG, + }); +}; + +const INITIAL_ORG_DETAILS: OrgDetails = { + organisationName: '', + usesObservability: true, + observabilityTool: '', + otherTool: '', + familiarity: '', +}; + +const INITIAL_SIGNOZ_DETAILS: SignozDetails = { + hearAboutSignoz: '', + interestInSignoz: '', + otherInterestInSignoz: '', + otherAboutSignoz: '', +}; + +const INITIAL_OPTIMISE_SIGNOZ_DETAILS: OptimiseSignozDetails = { + logsPerDay: 0, + hostsPerDay: 0, + services: 0, +}; + +const BACK_BUTTON_EVENT_NAME = 'Org Onboarding: Back Button Clicked'; +const NEXT_BUTTON_EVENT_NAME = 'Org Onboarding: Next Button Clicked'; +const ONBOARDING_COMPLETE_EVENT_NAME = 'Org Onboarding: Complete'; + +function OnboardingQuestionaire(): JSX.Element { + const { notifications } = useNotifications(); + const { org } = useSelector((state) => state.app); + const dispatch = useDispatch(); + const [currentStep, setCurrentStep] = useState(1); + const [orgDetails, setOrgDetails] = useState(INITIAL_ORG_DETAILS); + const [signozDetails, setSignozDetails] = useState( + INITIAL_SIGNOZ_DETAILS, + ); + + const [ + optimiseSignozDetails, + setOptimiseSignozDetails, + ] = useState(INITIAL_OPTIMISE_SIGNOZ_DETAILS); + const [teamMembers, setTeamMembers] = useState< + InviteTeamMembersProps[] | null + >(null); + + const [currentOrgData, setCurrentOrgData] = useState(null); + + const [ + updatingOrgOnboardingStatus, + setUpdatingOrgOnboardingStatus, + ] = useState(false); + + useEffect(() => { + if (org) { + setCurrentOrgData(org[0]); + + setOrgDetails({ + ...orgDetails, + organisationName: org[0].name, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [org]); + + useEffect(() => { + logEvent('Org Onboarding: Started', { + org_id: org?.[0]?.id, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { refetch: refetchOrgPreferences } = useQuery({ + queryFn: () => getAllOrgPreferences(), + queryKey: ['getOrgPreferences'], + enabled: false, + refetchOnWindowFocus: false, + onSuccess: (response) => { + dispatch({ + type: UPDATE_IS_FETCHING_ORG_PREFERENCES, + payload: { + isFetchingOrgPreferences: false, + }, + }); + + dispatch({ + type: UPDATE_ORG_PREFERENCES, + payload: { + orgPreferences: response.payload?.data || null, + }, + }); + + setUpdatingOrgOnboardingStatus(false); + + logEvent('Org Onboarding: Redirecting to Get Started', {}); + + history.push(ROUTES.GET_STARTED); + }, + onError: () => { + setUpdatingOrgOnboardingStatus(false); + }, + }); + + const isNextDisabled = + optimiseSignozDetails.logsPerDay === 0 && + optimiseSignozDetails.hostsPerDay === 0 && + optimiseSignozDetails.services === 0; + + const { mutate: updateProfile, isLoading: isUpdatingProfile } = useMutation( + updateProfileAPI, + { + onSuccess: () => { + setCurrentStep(4); + }, + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + }, + }, + ); + + const { mutate: updateOrgPreference } = useMutation(updateOrgPreferenceAPI, { + onSuccess: () => { + refetchOrgPreferences(); + }, + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + + setUpdatingOrgOnboardingStatus(false); + }, + }); + + const handleUpdateProfile = (): void => { + logEvent(NEXT_BUTTON_EVENT_NAME, { + currentPageID: 3, + nextPageID: 4, + }); + + updateProfile({ + familiarity_with_observability: orgDetails?.familiarity as string, + has_existing_observability_tool: orgDetails?.usesObservability as boolean, + existing_observability_tool: + orgDetails?.observabilityTool === 'Others' + ? (orgDetails?.otherTool as string) + : (orgDetails?.observabilityTool as string), + + reasons_for_interest_in_signoz: + signozDetails?.interestInSignoz === 'Others' + ? (signozDetails?.otherInterestInSignoz as string) + : (signozDetails?.interestInSignoz as string), + where_did_you_hear_about_signoz: + signozDetails?.hearAboutSignoz === 'Others' + ? (signozDetails?.otherAboutSignoz as string) + : (signozDetails?.hearAboutSignoz as string), + + logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number, + number_of_hosts: optimiseSignozDetails?.hostsPerDay as number, + number_of_services: optimiseSignozDetails?.services as number, + }); + }; + + const handleOnboardingComplete = (): void => { + logEvent(ONBOARDING_COMPLETE_EVENT_NAME, { + currentPageID: 4, + }); + + setUpdatingOrgOnboardingStatus(true); + updateOrgPreference({ + preferenceID: 'ORG_ONBOARDING', + value: true, + }); + }; + + return ( +
+
+ +
+ +
+ {currentStep === 1 && ( + { + logEvent(NEXT_BUTTON_EVENT_NAME, { + currentPageID: 1, + nextPageID: 2, + }); + + setOrgDetails(orgDetails); + setCurrentStep(2); + }} + /> + )} + + {currentStep === 2 && ( + { + logEvent(BACK_BUTTON_EVENT_NAME, { + currentPageID: 2, + prevPageID: 1, + }); + setCurrentStep(1); + }} + onNext={(): void => { + logEvent(NEXT_BUTTON_EVENT_NAME, { + currentPageID: 2, + nextPageID: 3, + }); + setCurrentStep(3); + }} + /> + )} + + {currentStep === 3 && ( + { + logEvent(BACK_BUTTON_EVENT_NAME, { + currentPageID: 3, + prevPageID: 2, + }); + setCurrentStep(2); + }} + onNext={handleUpdateProfile} + onWillDoLater={(): void => setCurrentStep(4)} + /> + )} + + {currentStep === 4 && ( + { + logEvent(BACK_BUTTON_EVENT_NAME, { + currentPageID: 4, + prevPageID: 3, + }); + setCurrentStep(3); + }} + onNext={handleOnboardingComplete} + /> + )} +
+
+ ); +} + +export default OnboardingQuestionaire; diff --git a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx index 7c4909ab8dce..07c1d8f219b7 100644 --- a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx +++ b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx @@ -236,7 +236,9 @@ function PendingInvitesContainer(): JSX.Element { export interface InviteTeamMembersProps { email: string; name: string; - role: ROLES; + role: string; + id: string; + frontendBaseUrl: string; } interface DataProps { diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx index 36c54aa9a1f1..60905fa33bc4 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx @@ -81,8 +81,10 @@ export const AggregatorFilter = memo(function AggregatorFilter({ prefix: item.type || '', condition: !item.isColumn, }), + !item.isColumn && item.type ? item.type : '', )} dataType={item.dataType} + type={item.type || ''} /> ), value: `${item.key}${selectValueDivider}${createIdFromObjectFields( @@ -187,6 +189,9 @@ export const AggregatorFilter = memo(function AggregatorFilter({ prefix: query.aggregateAttribute.type || '', condition: !query.aggregateAttribute.isColumn, }), + !query.aggregateAttribute.isColumn && query.aggregateAttribute.type + ? query.aggregateAttribute.type + : '', ); return ( diff --git a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx index bed38705708f..476bf71f217d 100644 --- a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx @@ -75,8 +75,10 @@ export const GroupByFilter = memo(function GroupByFilter({ prefix: item.type || '', condition: !item.isColumn, }), + !item.isColumn && item.type ? item.type : '', )} dataType={item.dataType || ''} + type={item.type || ''} /> ), value: `${item.id}`, @@ -166,6 +168,7 @@ export const GroupByFilter = memo(function GroupByFilter({ prefix: item.type || '', condition: !item.isColumn, }), + !item.isColumn && item.type ? item.type : '', )}`, value: `${item.id}`, }), diff --git a/frontend/src/container/QueryBuilder/filters/GroupByFilter/utils.ts b/frontend/src/container/QueryBuilder/filters/GroupByFilter/utils.ts index 50dccec4d93d..0fb85a7e3089 100644 --- a/frontend/src/container/QueryBuilder/filters/GroupByFilter/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/GroupByFilter/utils.ts @@ -1,8 +1,9 @@ import { MetricsType } from 'container/MetricsApplication/constant'; -export function removePrefix(str: string): string { +export function removePrefix(str: string, type: string): string { const tagPrefix = `${MetricsType.Tag}_`; const resourcePrefix = `${MetricsType.Resource}_`; + const scopePrefix = `${MetricsType.Scope}_`; if (str.startsWith(tagPrefix)) { return str.slice(tagPrefix.length); @@ -10,5 +11,9 @@ export function removePrefix(str: string): string { if (str.startsWith(resourcePrefix)) { return str.slice(resourcePrefix.length); } + if (str.startsWith(scopePrefix) && type === MetricsType.Scope) { + return str.slice(scopePrefix.length); + } + return str; } diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx index a7dcef96c312..a93041f5e8dc 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx @@ -3,25 +3,23 @@ import './QueryBuilderSearch.styles.scss'; import { Tooltip } from 'antd'; import { TagContainer, TagLabel, TagValue } from './style'; -import { getOptionType } from './utils'; function OptionRenderer({ label, value, dataType, + type, }: OptionRendererProps): JSX.Element { - const optionType = getOptionType(label); - return ( - {optionType ? ( + {type ? (
{value}
Type: - {optionType} + {type} Data type: @@ -43,6 +41,7 @@ interface OptionRendererProps { label: string; value: string; dataType: string; + type: string; } export default OptionRenderer; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx index c1f4b85a1197..ba30d96d9c57 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx @@ -410,6 +410,7 @@ function QueryBuilderSearch({ label={option.label} value={option.value} dataType={option.dataType || ''} + type={option.type || ''} /> {option.selected && } diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss index 7aee4f9414fe..60eec0bdb65b 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss @@ -260,6 +260,20 @@ background: rgba(189, 153, 121, 0.1); } } + + &.scope { + border: 1px solid rgba(113, 144, 249, 0.2); + + .ant-typography { + color: var(--bg-robin-400); + background: rgba(113, 144, 249, 0.1); + font-size: 14px; + } + + .ant-tag-close-icon { + background: rgba(113, 144, 249, 0.1); + } + } } } } diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss index 1b434316e5f8..bff02fab3e31 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss @@ -94,6 +94,25 @@ letter-spacing: -0.06px; } } + + &.scope { + border-radius: 50px; + background: rgba(113, 144, 249, 0.1) !important; + color: var(--bg-robin-400) !important; + + .dot { + background-color: var(--bg-robin-400); + } + .text { + color: var(--bg-robin-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: -0.06px; + } + } } } .option-meta-data-container { diff --git a/frontend/src/container/QueryBuilder/type.ts b/frontend/src/container/QueryBuilder/type.ts index 183dd157f804..d20925e33021 100644 --- a/frontend/src/container/QueryBuilder/type.ts +++ b/frontend/src/container/QueryBuilder/type.ts @@ -16,4 +16,5 @@ export type Option = { selected?: boolean; dataType?: string; isIndexed?: boolean; + type?: string; }; diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index 16787bc3d8d5..8ccafacb0aaa 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -113,7 +113,9 @@ function SideNav({ if (!isOnboardingEnabled || !isCloudUser()) { let items = [...menuItems]; - items = items.filter((item) => item.key !== ROUTES.GET_STARTED); + items = items.filter( + (item) => item.key !== ROUTES.GET_STARTED && item.key !== ROUTES.ONBOARDING, + ); setMenuItems(items); } diff --git a/frontend/src/container/SideNav/config.ts b/frontend/src/container/SideNav/config.ts index 37e9db4d9f08..7fdd462a527f 100644 --- a/frontend/src/container/SideNav/config.ts +++ b/frontend/src/container/SideNav/config.ts @@ -27,6 +27,7 @@ export const routeConfig: Record = { [ROUTES.ERROR_DETAIL]: [QueryParams.resourceAttributes], [ROUTES.HOME_PAGE]: [QueryParams.resourceAttributes], [ROUTES.GET_STARTED]: [QueryParams.resourceAttributes], + [ROUTES.ONBOARDING]: [QueryParams.resourceAttributes], [ROUTES.LIST_ALL_ALERT]: [QueryParams.resourceAttributes], [ROUTES.LIST_LICENSES]: [QueryParams.resourceAttributes], [ROUTES.LOGIN]: [QueryParams.resourceAttributes], diff --git a/frontend/src/hooks/messagingQueue / onboarding/useOnboardingStatus.tsx b/frontend/src/hooks/messagingQueue / onboarding/useOnboardingStatus.tsx new file mode 100644 index 000000000000..13ecd15b8b0e --- /dev/null +++ b/frontend/src/hooks/messagingQueue / onboarding/useOnboardingStatus.tsx @@ -0,0 +1,29 @@ +import getOnboardingStatus, { + OnboardingStatusResponse, +} from 'api/messagingQueues/onboarding/getOnboardingStatus'; +import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +type UseOnboardingStatus = ( + options?: UseQueryOptions< + SuccessResponse | ErrorResponse + >, + endpointService?: string, + queryKey?: string, +) => UseQueryResult | ErrorResponse>; + +export const useOnboardingStatus: UseOnboardingStatus = ( + options, + endpointService, + queryKey, +) => + useQuery | ErrorResponse>({ + queryKey: [queryKey || `onboardingStatus-${endpointService}`], + queryFn: () => + getOnboardingStatus({ + start: (Date.now() - 15 * 60 * 1000) * 1_000_000, + end: Date.now() * 1_000_000, + endpointService, + }), + ...options, + }); diff --git a/frontend/src/hooks/queryBuilder/useOptions.ts b/frontend/src/hooks/queryBuilder/useOptions.ts index 2f24dd0d21b4..e990f789dee7 100644 --- a/frontend/src/hooks/queryBuilder/useOptions.ts +++ b/frontend/src/hooks/queryBuilder/useOptions.ts @@ -46,6 +46,7 @@ export const useOptions = ( value: item.key, dataType: item.dataType, isIndexed: item?.isIndexed, + type: item?.type || '', })), [getLabel], ); diff --git a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts index 78617669a268..9a42a6df9400 100644 --- a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts +++ b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts @@ -163,7 +163,8 @@ export const getUPlotChartOptions = ({ const stackBarChart = stackChart && isUndefined(hiddenGraph); - const isAnomalyRule = apiResponse?.data?.newResult?.data?.result[0].isAnomaly; + const isAnomalyRule = + apiResponse?.data?.newResult?.data?.result[0]?.isAnomaly || false; const series = getStackedSeries(apiResponse?.data?.result || []); diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 8a630a6374c4..b4d7674c6798 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -57,8 +57,11 @@ export const useAlertHistoryQueryParams = (): { const startTime = params.get(QueryParams.startTime); const endTime = params.get(QueryParams.endTime); + const relativeTimeParam = params.get(QueryParams.relativeTime); + const relativeTime = - params.get(QueryParams.relativeTime) ?? RelativeTimeMap['6hr']; + (relativeTimeParam === 'null' ? null : relativeTimeParam) ?? + RelativeTimeMap['6hr']; const intStartTime = parseInt(startTime || '0', 10); const intEndTime = parseInt(endTime || '0', 10); diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx index 5b5ef631b0d8..f49aec6923c8 100644 --- a/frontend/src/pages/LogsExplorer/utils.tsx +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -66,9 +66,9 @@ export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ type: FiltersType.CHECKBOX, title: 'Hostname', attributeKey: { - key: 'hostname', + key: 'host.name', dataType: DataTypes.String, - type: 'tag', + type: 'resource', isColumn: false, isJSON: false, }, diff --git a/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx b/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx index 8fa697f6af84..5ee98249f6ae 100644 --- a/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx +++ b/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx @@ -1,26 +1,65 @@ +/* eslint-disable no-nested-ternary */ import '../MessagingQueues.styles.scss'; import { Select, Typography } from 'antd'; import logEvent from 'api/common/logEvent'; +import { QueryParams } from 'constants/query'; import ROUTES from 'constants/routes'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; +import useUrlQuery from 'hooks/useUrlQuery'; import { ListMinus } from 'lucide-react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { MessagingQueuesViewType } from '../MessagingQueuesUtils'; -import { SelectLabelWithComingSoon } from '../MQCommon/MQCommon'; +import { + MessagingQueuesViewType, + MessagingQueuesViewTypeOptions, + ProducerLatencyOptions, +} from '../MessagingQueuesUtils'; +import DropRateView from '../MQDetails/DropRateView/DropRateView'; +import MessagingQueueOverview from '../MQDetails/MessagingQueueOverview'; import MessagingQueuesDetails from '../MQDetails/MQDetails'; import MessagingQueuesConfigOptions from '../MQGraph/MQConfigOptions'; import MessagingQueuesGraph from '../MQGraph/MQGraph'; function MQDetailPage(): JSX.Element { const history = useHistory(); + const [ + selectedView, + setSelectedView, + ] = useState( + MessagingQueuesViewType.consumerLag.value, + ); + + const [ + producerLatencyOption, + setproducerLatencyOption, + ] = useState(ProducerLatencyOptions.Producers); + + const mqServiceView = useUrlQuery().get( + QueryParams.mqServiceView, + ) as MessagingQueuesViewTypeOptions; useEffect(() => { logEvent('Messaging Queues: Detail page visited', {}); }, []); + useEffect(() => { + if (mqServiceView) { + setSelectedView(mqServiceView); + } + }, [mqServiceView]); + + const updateUrlQuery = (query: Record): void => { + const searchParams = new URLSearchParams(history.location.search); + Object.keys(query).forEach((key) => { + searchParams.set(key, query[key].toString()); + }); + history.push({ + search: searchParams.toString(), + }); + }; + return (
@@ -39,50 +78,55 @@ function MQDetailPage(): JSX.Element { className="messaging-queue-options" defaultValue={MessagingQueuesViewType.consumerLag.value} popupClassName="messaging-queue-options-popup" + onChange={(value): void => { + setSelectedView(value); + updateUrlQuery({ [QueryParams.mqServiceView]: value }); + }} + value={mqServiceView} options={[ { label: MessagingQueuesViewType.consumerLag.label, value: MessagingQueuesViewType.consumerLag.value, }, { - label: ( - - ), + label: MessagingQueuesViewType.partitionLatency.label, value: MessagingQueuesViewType.partitionLatency.value, - disabled: true, }, { - label: ( - - ), + label: MessagingQueuesViewType.producerLatency.label, value: MessagingQueuesViewType.producerLatency.value, - disabled: true, }, { - label: ( - - ), - value: MessagingQueuesViewType.consumerLatency.value, - disabled: true, + label: MessagingQueuesViewType.dropRate.label, + value: MessagingQueuesViewType.dropRate.value, }, ]} />
-
- - -
-
- -
+ {selectedView === MessagingQueuesViewType.consumerLag.value ? ( +
+ + +
+ ) : selectedView === MessagingQueuesViewType.dropRate.value ? ( + + ) : ( + + )} + {selectedView !== MessagingQueuesViewType.dropRate.value && ( +
+ +
+ )}
); } diff --git a/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.styles.scss b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.styles.scss new file mode 100644 index 000000000000..b36a46a8ec0b --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.styles.scss @@ -0,0 +1,30 @@ +.evaluation-time-selector { + display: flex; + align-items: center; + gap: 8px; + + .eval-title { + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 28px; + color: var(--bg-vanilla-200); + } + + .ant-selector { + background-color: var(--bg-ink-400); + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + box-shadow: none; + } +} + +.select-dropdown-render { + padding: 8px; + display: flex; + justify-content: center; + align-items: center; + width: 200px; + margin: 6px; +} diff --git a/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.tsx b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.tsx new file mode 100644 index 000000000000..28d9f65d4474 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/DropRateView.tsx @@ -0,0 +1,251 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import '../MQDetails.style.scss'; + +import { Table, Typography } from 'antd'; +import axios from 'axios'; +import cx from 'classnames'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import ROUTES from 'constants/routes'; +import { useNotifications } from 'hooks/useNotifications'; +import { isNumber } from 'lodash-es'; +import { + convertToTitleCase, + MessagingQueuesViewType, + RowData, +} from 'pages/MessagingQueues/MessagingQueuesUtils'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { MessagingQueueServicePayload } from '../MQTables/getConsumerLagDetails'; +import { getKafkaSpanEval } from '../MQTables/getKafkaSpanEval'; +import { + convertToMilliseconds, + DropRateAPIResponse, + DropRateResponse, +} from './dropRateViewUtils'; +import EvaluationTimeSelector from './EvaluationTimeSelector'; + +export function getTableData(data: DropRateResponse[]): RowData[] { + if (data?.length === 0) { + return []; + } + + const tableData: RowData[] = + data?.map( + (row: DropRateResponse, index: number): RowData => ({ + ...(row.data as any), // todo-sagar + key: index, + }), + ) || []; + + return tableData; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function getColumns( + data: DropRateResponse[], + visibleCounts: Record, + handleShowMore: (index: number) => void, +): any[] { + if (data?.length === 0) { + return []; + } + + const columnsOrder = [ + 'producer_service', + 'consumer_service', + 'breach_percentage', + 'top_traceIDs', + 'breached_spans', + 'total_spans', + ]; + + const columns: { + title: string; + dataIndex: string; + key: string; + }[] = columnsOrder.map((column) => ({ + title: convertToTitleCase(column), + dataIndex: column, + key: column, + render: ( + text: string | string[], + _record: any, + index: number, + ): JSX.Element => { + if (Array.isArray(text)) { + const visibleCount = visibleCounts[index] || 4; + const visibleItems = text.slice(0, visibleCount); + const remainingCount = (text || []).length - visibleCount; + + return ( +
+
+ {visibleItems.map((item, idx) => { + const shouldShowMore = remainingCount > 0 && idx === visibleCount - 1; + return ( +
+ { + window.open(`${ROUTES.TRACE}/${item}`, '_blank'); + }} + > + {item} + + {shouldShowMore && ( + handleShowMore(index)} + className="remaing-count" + > + + {remainingCount} more + + )} +
+ ); + })} +
+
+ ); + } + + if (column === 'consumer_service' || column === 'producer_service') { + return ( + { + e.preventDefault(); + e.stopPropagation(); + window.open(`/services/${encodeURIComponent(text)}`, '_blank'); + }} + > + {text} + + ); + } + + if (column === 'breach_percentage' && text) { + if (!isNumber(text)) + return {text.toString()}; + return ( + + {(typeof text === 'string' ? parseFloat(text) : text).toFixed(2)} % + + ); + } + + return {text}; + }, + })); + + return columns; +} + +const showPaginationItem = (total: number, range: number[]): JSX.Element => ( + <> + + {range[0]} — {range[1]} + + of {total} + +); + +function DropRateView(): JSX.Element { + const [columns, setColumns] = useState([]); + const [tableData, setTableData] = useState([]); + const { notifications } = useNotifications(); + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + const [data, setData] = useState< + DropRateAPIResponse['data']['result'][0]['list'] + >([]); + const [interval, setInterval] = useState(''); + + const [visibleCounts, setVisibleCounts] = useState>({}); + + const paginationConfig = useMemo( + () => + tableData?.length > 10 && { + pageSize: 10, + showTotal: showPaginationItem, + showSizeChanger: false, + hideOnSinglePage: true, + }, + [tableData], + ); + + const evaluationTime = useMemo(() => convertToMilliseconds(interval), [ + interval, + ]); + const tableApiPayload: MessagingQueueServicePayload = useMemo( + () => ({ + start: minTime, + end: maxTime, + evalTime: evaluationTime * 1e6, + }), + [evaluationTime, maxTime, minTime], + ); + + const handleOnError = (error: Error): void => { + notifications.error({ + message: axios.isAxiosError(error) ? error?.message : SOMETHING_WENT_WRONG, + }); + }; + + const handleShowMore = (index: number): void => { + setVisibleCounts((prevCounts) => ({ + ...prevCounts, + [index]: (prevCounts[index] || 4) + 4, + })); + }; + + const { mutate: getViewDetails, isLoading } = useMutation(getKafkaSpanEval, { + onSuccess: (data) => { + if (data.payload) { + setData(data.payload.result[0].list); + } + }, + onError: handleOnError, + }); + + useEffect(() => { + if (data?.length > 0) { + setColumns(getColumns(data, visibleCounts, handleShowMore)); + setTableData(getTableData(data)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, visibleCounts]); + + useEffect(() => { + if (evaluationTime) { + getViewDetails(tableApiPayload); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minTime, maxTime, evaluationTime]); + + return ( +
+
+
+ {MessagingQueuesViewType.dropRate.label} +
+ +
+ + + ); +} + +export default DropRateView; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/EvaluationTimeSelector.tsx b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/EvaluationTimeSelector.tsx new file mode 100644 index 000000000000..2ca2e9c301ac --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/EvaluationTimeSelector.tsx @@ -0,0 +1,111 @@ +import './DropRateView.styles.scss'; + +import { Input, Select, Typography } from 'antd'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +const { Option } = Select; + +interface SelectDropdownRenderProps { + menu: React.ReactNode; + inputValue: string; + handleInputChange: (e: React.ChangeEvent) => void; + handleKeyDown: (e: React.KeyboardEvent) => void; + handleAddCustomValue: () => void; +} + +function SelectDropdownRender({ + menu, + inputValue, + handleInputChange, + handleAddCustomValue, + handleKeyDown, +}: SelectDropdownRenderProps): JSX.Element { + return ( + <> + {menu} + + + ); +} + +function EvaluationTimeSelector({ + setInterval, +}: { + setInterval: Dispatch>; +}): JSX.Element { + const [inputValue, setInputValue] = useState(''); + const [selectedInterval, setSelectedInterval] = useState('5ms'); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const handleInputChange = (e: React.ChangeEvent): void => { + setInputValue(e.target.value); + }; + + const handleSelectChange = (value: string): void => { + setSelectedInterval(value); + setInputValue(''); + setDropdownOpen(false); + }; + + const handleAddCustomValue = (): void => { + setSelectedInterval(inputValue); + setInputValue(inputValue); + setDropdownOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleAddCustomValue(); + } + }; + + const renderDropdown = (menu: React.ReactNode): JSX.Element => ( + + ); + + useEffect(() => { + if (selectedInterval) { + setInterval(() => selectedInterval); + } + }, [selectedInterval, setInterval]); + + return ( +
+ + Evaluation Interval: + + +
+ ); +} + +export default EvaluationTimeSelector; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/dropRateViewUtils.ts b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/dropRateViewUtils.ts new file mode 100644 index 000000000000..49d751e72246 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/DropRateView/dropRateViewUtils.ts @@ -0,0 +1,46 @@ +export function convertToMilliseconds(timeInput: string): number { + if (!timeInput.trim()) { + return 0; + } + + const match = timeInput.match(/^(\d+)(ms|s|ns)?$/); // Match number and optional unit + if (!match) { + throw new Error(`Invalid time format: ${timeInput}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2] || 'ms'; // Default to 'ms' if no unit is provided + + switch (unit) { + case 's': + return value * 1e3; + case 'ms': + return value; + case 'ns': + return value / 1e6; + default: + throw new Error('Invalid time format'); + } +} + +export interface DropRateResponse { + timestamp: string; + data: { + breach_percentage: number; + breached_spans: number; + consumer_service: string; + producer_service: string; + top_traceIDs: string[]; + total_spans: number; + }; +} +export interface DropRateAPIResponse { + status: string; + data: { + resultType: string; + result: { + queryName: string; + list: DropRateResponse[]; + }[]; + }; +} diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.style.scss b/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.style.scss index 68014823da11..5a746bbcae92 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.style.scss +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.style.scss @@ -4,3 +4,115 @@ flex-direction: column; gap: 24px; } + +.mq-overview-container { + display: flex; + padding: 24px; + flex-direction: column; + align-items: start; + gap: 16px; + + border-radius: 6px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-500); + + .mq-overview-title { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + .drop-rat-title { + color: var(--bg-vanilla-200); + + font-family: Inter; + font-size: 18px; + font-style: normal; + font-weight: 500; + line-height: 28px; + } + } + + .mq-details-options { + letter-spacing: -0.06px; + cursor: pointer; + + .ant-radio-button-wrapper { + border-color: var(--bg-slate-400); + color: var(--bg-vanilla-400); + } + .ant-radio-button-wrapper-checked { + background: var(--bg-slate-400); + color: var(--bg-vanilla-100); + } + .ant-radio-button-wrapper::before { + width: 0px; + } + } +} + +.droprate-view { + .mq-table { + width: 100%; + + .ant-table-content { + border-radius: 6px; + border: 1px solid var(--bg-slate-500); + box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1); + } + + .ant-table-tbody { + .ant-table-cell { + max-width: 250px; + border-bottom: none; + } + } + + .ant-table-thead { + .ant-table-cell { + background-color: var(--bg-ink-500); + border-bottom: 1px solid var(--bg-slate-500); + } + } + } + + .trace-id-list { + display: flex; + flex-direction: column; + gap: 4px; + width: max-content; + + .traceid-style { + display: flex; + gap: 8px; + align-items: center; + + .traceid-text { + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-slate-400); + padding: 2px; + cursor: pointer; + } + + .remaing-count { + cursor: pointer; + color: var(--bg-vanilla-100); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: -0.06px; + } + } + } +} + +.pagination-left { + &.mq-table { + .ant-pagination { + justify-content: flex-start; + } + } +} diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.tsx b/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.tsx index df1f643daa2e..3609b2d22677 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.tsx +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQDetails.tsx @@ -1,65 +1,222 @@ import './MQDetails.style.scss'; import { Radio } from 'antd'; -import { Dispatch, SetStateAction, useState } from 'react'; +import { QueryParams } from 'constants/query'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { isEmpty } from 'lodash-es'; +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; import { ConsumerLagDetailTitle, - ConsumerLagDetailType, + getMetaDataAndAPIPerView, + MessagingQueueServiceDetailType, + MessagingQueuesViewType, + MessagingQueuesViewTypeOptions, + ProducerLatencyOptions, + SelectedTimelineQuery, } from '../MessagingQueuesUtils'; import { ComingSoon } from '../MQCommon/MQCommon'; import MessagingQueuesTable from './MQTables/MQTables'; +const MQServiceDetailTypePerView = ( + producerLatencyOption: ProducerLatencyOptions, +): Record => ({ + [MessagingQueuesViewType.consumerLag.value]: [ + MessagingQueueServiceDetailType.ConsumerDetails, + MessagingQueueServiceDetailType.ProducerDetails, + MessagingQueueServiceDetailType.NetworkLatency, + MessagingQueueServiceDetailType.PartitionHostMetrics, + ], + [MessagingQueuesViewType.partitionLatency.value]: [ + MessagingQueueServiceDetailType.ConsumerDetails, + MessagingQueueServiceDetailType.ProducerDetails, + ], + [MessagingQueuesViewType.producerLatency.value]: [ + producerLatencyOption === ProducerLatencyOptions.Consumers + ? MessagingQueueServiceDetailType.ConsumerDetails + : MessagingQueueServiceDetailType.ProducerDetails, + ], +}); + +interface MessagingQueuesOptionsProps { + currentTab: MessagingQueueServiceDetailType; + setCurrentTab: Dispatch>; + selectedView: MessagingQueuesViewTypeOptions; + producerLatencyOption: ProducerLatencyOptions; +} + function MessagingQueuesOptions({ currentTab, setCurrentTab, -}: { - currentTab: ConsumerLagDetailType; - setCurrentTab: Dispatch>; -}): JSX.Element { - const [option, setOption] = useState(currentTab); + selectedView, + producerLatencyOption, +}: MessagingQueuesOptionsProps): JSX.Element { + const handleChange = (value: MessagingQueueServiceDetailType): void => { + setCurrentTab(value); + }; + + const renderRadioButtons = (): JSX.Element[] => { + const detailTypes = + MQServiceDetailTypePerView(producerLatencyOption)[selectedView] || []; + return detailTypes.map((detailType) => ( + + {ConsumerLagDetailTitle[detailType]} + {detailType === MessagingQueueServiceDetailType.PartitionHostMetrics && ( + + )} + + )); + }; return ( { - setOption(value.target.value); - setCurrentTab(value.target.value); - }} - value={option} + onChange={(e): void => handleChange(e.target.value)} + value={currentTab} className="mq-details-options" > - - {ConsumerLagDetailTitle[ConsumerLagDetailType.ConsumerDetails]} - - - {ConsumerLagDetailTitle[ConsumerLagDetailType.ProducerDetails]} - - - {ConsumerLagDetailTitle[ConsumerLagDetailType.NetworkLatency]} - - - {ConsumerLagDetailTitle[ConsumerLagDetailType.PartitionHostMetrics]} - - + {renderRadioButtons()} ); } -function MessagingQueuesDetails(): JSX.Element { - const [currentTab, setCurrentTab] = useState( - ConsumerLagDetailType.ConsumerDetails, +const checkValidityOfDetailConfigs = ( + selectedTimelineQuery: SelectedTimelineQuery, + selectedView: MessagingQueuesViewTypeOptions, + currentTab: MessagingQueueServiceDetailType, + configDetails?: { + [key: string]: string; + }, + // eslint-disable-next-line sonarjs/cognitive-complexity +): boolean => { + if (selectedView === MessagingQueuesViewType.consumerLag.value) { + return !( + isEmpty(selectedTimelineQuery) || + (!selectedTimelineQuery?.group && + !selectedTimelineQuery?.topic && + !selectedTimelineQuery?.partition) + ); + } + + if (selectedView === MessagingQueuesViewType.partitionLatency.value) { + if (isEmpty(configDetails)) { + return false; + } + + return Boolean(configDetails?.topic && configDetails?.partition); + } + + if (selectedView === MessagingQueuesViewType.producerLatency.value) { + if (isEmpty(configDetails)) { + return false; + } + + if (currentTab === MessagingQueueServiceDetailType.ProducerDetails) { + return Boolean( + configDetails?.topic && + configDetails?.partition && + configDetails?.service_name, + ); + } + return Boolean(configDetails?.topic && configDetails?.service_name); + } + + return selectedView === MessagingQueuesViewType.dropRate.value; +}; + +function MessagingQueuesDetails({ + selectedView, + producerLatencyOption, +}: { + selectedView: MessagingQueuesViewTypeOptions; + producerLatencyOption: ProducerLatencyOptions; +}): JSX.Element { + const [currentTab, setCurrentTab] = useState( + MessagingQueueServiceDetailType.ConsumerDetails, ); + + useEffect(() => { + if ( + producerLatencyOption && + selectedView === MessagingQueuesViewType.producerLatency.value + ) { + setCurrentTab( + producerLatencyOption === ProducerLatencyOptions.Consumers + ? MessagingQueueServiceDetailType.ConsumerDetails + : MessagingQueueServiceDetailType.ProducerDetails, + ); + } + }, [selectedView, producerLatencyOption]); + + const urlQuery = useUrlQuery(); + const timelineQuery = decodeURIComponent( + urlQuery.get(QueryParams.selectedTimelineQuery) || '', + ); + + const timelineQueryData: SelectedTimelineQuery = useMemo( + () => (timelineQuery ? JSON.parse(timelineQuery) : {}), + [timelineQuery], + ); + + const configDetails = decodeURIComponent( + urlQuery.get(QueryParams.configDetail) || '', + ); + + const configDetailQueryData: { + [key: string]: string; + } = useMemo(() => (configDetails ? JSON.parse(configDetails) : {}), [ + configDetails, + ]); + + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const serviceConfigDetails = useMemo( + () => + getMetaDataAndAPIPerView({ + detailType: currentTab, + minTime, + maxTime, + selectedTimelineQuery: timelineQueryData, + configDetails: configDetailQueryData, + }), + [configDetailQueryData, currentTab, maxTime, minTime, timelineQueryData], + ); + return (
+ -
); } diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.styles.scss b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.styles.scss index e02e19e8902a..ad665d61f5b4 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.styles.scss +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.styles.scss @@ -1,4 +1,7 @@ .mq-tables-container { + width: 100%; + height: 100%; + .mq-table-title { display: flex; align-items: center; @@ -31,9 +34,6 @@ .ant-table-tbody { .ant-table-cell { max-width: 250px; - - background-color: var(--bg-ink-400); - border-bottom: none; } } @@ -63,6 +63,21 @@ } } +.mq-table { + &.mq-overview-row-clickable { + .ant-table-row { + background-color: var(--bg-ink-400); + + &:hover { + cursor: pointer; + background-color: var(--bg-slate-400) !important; + color: var(--bg-vanilla-400); + transition: background-color 0.3s ease, color 0.3s ease; + } + } + } +} + .lightMode { .mq-tables-container { .mq-table-title { diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx index 9555f7f22803..73fd1b2f41ab 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx @@ -1,9 +1,11 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/require-default-props */ import './MQTables.styles.scss'; import { Skeleton, Table, Typography } from 'antd'; -import logEvent from 'api/common/logEvent'; import axios from 'axios'; import { isNumber } from 'chart.js/helpers'; +import cx from 'classnames'; import { ColumnTypeRender } from 'components/Logs/TableView/types'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryParams } from 'constants/query'; @@ -13,27 +15,31 @@ import useUrlQuery from 'hooks/useUrlQuery'; import { isEmpty } from 'lodash-es'; import { ConsumerLagDetailTitle, - ConsumerLagDetailType, convertToTitleCase, + MessagingQueueServiceDetailType, + MessagingQueuesViewType, + MessagingQueuesViewTypeOptions, RowData, SelectedTimelineQuery, + setConfigDetail, } from 'pages/MessagingQueues/MessagingQueuesUtils'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useMutation } from 'react-query'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import { ErrorResponse, SuccessResponse } from 'types/api'; import { - ConsumerLagPayload, - getConsumerLagDetails, + MessagingQueueServicePayload, MessagingQueuesPayloadProps, } from './getConsumerLagDetails'; +const INITIAL_PAGE_SIZE = 10; + // eslint-disable-next-line sonarjs/cognitive-complexity export function getColumns( data: MessagingQueuesPayloadProps['payload'], history: History, ): RowData[] { - console.log(data); if (data?.result?.length === 0) { return []; } @@ -105,10 +111,25 @@ const showPaginationItem = (total: number, range: number[]): JSX.Element => ( ); +// eslint-disable-next-line sonarjs/cognitive-complexity function MessagingQueuesTable({ currentTab, + selectedView, + tableApiPayload, + tableApi, + validConfigPresent = false, + type = 'Detail', }: { - currentTab: ConsumerLagDetailType; + currentTab?: MessagingQueueServiceDetailType; + selectedView: MessagingQueuesViewTypeOptions; + tableApiPayload?: MessagingQueueServicePayload; + tableApi: ( + props: MessagingQueueServicePayload, + ) => Promise< + SuccessResponse | ErrorResponse + >; + validConfigPresent?: boolean; + type?: 'Detail' | 'Overview'; }): JSX.Element { const [columns, setColumns] = useState([]); const [tableData, setTableData] = useState([]); @@ -118,15 +139,26 @@ function MessagingQueuesTable({ const timelineQuery = decodeURIComponent( urlQuery.get(QueryParams.selectedTimelineQuery) || '', ); + const timelineQueryData: SelectedTimelineQuery = useMemo( () => (timelineQuery ? JSON.parse(timelineQuery) : {}), [timelineQuery], ); + const configDetails = decodeURIComponent( + urlQuery.get(QueryParams.configDetail) || '', + ); + + const configDetailQueryData: { + [key: string]: string; + } = useMemo(() => (configDetails ? JSON.parse(configDetails) : {}), [ + configDetails, + ]); + const paginationConfig = useMemo( () => - tableData?.length > 20 && { - pageSize: 20, + tableData?.length > INITIAL_PAGE_SIZE && { + pageSize: INITIAL_PAGE_SIZE, showTotal: showPaginationItem, showSizeChanger: false, hideOnSinglePage: true, @@ -134,28 +166,14 @@ function MessagingQueuesTable({ [tableData], ); - const props: ConsumerLagPayload = useMemo( - () => ({ - start: (timelineQueryData?.start || 0) * 1e9, - end: (timelineQueryData?.end || 0) * 1e9, - variables: { - partition: timelineQueryData?.partition, - topic: timelineQueryData?.topic, - consumer_group: timelineQueryData?.group, - }, - detailType: currentTab, - }), - [currentTab, timelineQueryData], - ); - const handleConsumerDetailsOnError = (error: Error): void => { notifications.error({ message: axios.isAxiosError(error) ? error?.message : SOMETHING_WENT_WRONG, }); }; - const { mutate: getConsumerDetails, isLoading } = useMutation( - getConsumerLagDetails, + const { mutate: getViewDetails, isLoading, error, isError } = useMutation( + tableApi, { onSuccess: (data) => { if (data.payload) { @@ -167,57 +185,92 @@ function MessagingQueuesTable({ }, ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => getConsumerDetails(props), [currentTab, props]); + useEffect( + () => { + if (validConfigPresent && tableApiPayload) { + getViewDetails(tableApiPayload); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentTab, selectedView, tableApiPayload], + ); - const isLogEventCalled = useRef(false); + const [selectedRowKey, setSelectedRowKey] = useState(); + const [, setSelectedRows] = useState(); + const location = useLocation(); - const isEmptyDetails = (timelineQueryData: SelectedTimelineQuery): boolean => { - const isEmptyDetail = - isEmpty(timelineQueryData) || - (!timelineQueryData?.group && - !timelineQueryData?.topic && - !timelineQueryData?.partition); + const onRowClick = (record: { [key: string]: string }): void => { + const selectedKey = record.key; - if (!isEmptyDetail && !isLogEventCalled.current) { - logEvent('Messaging Queues: More details viewed', { - 'tab-option': ConsumerLagDetailTitle[currentTab], - variables: { - group: timelineQueryData?.group, - topic: timelineQueryData?.topic, - partition: timelineQueryData?.partition, - }, - }); - isLogEventCalled.current = true; + if (`${selectedKey}_${selectedView}` === selectedRowKey) { + setSelectedRowKey(undefined); + setSelectedRows({}); + setConfigDetail(urlQuery, location, history, {}); + } else { + setSelectedRowKey(`${selectedKey}_${selectedView}`); + setSelectedRows(record); + + if (!isEmpty(record)) { + setConfigDetail(urlQuery, location, history, record); + } } - return isEmptyDetail; }; + const subtitle = + selectedView === MessagingQueuesViewType.consumerLag.value + ? `${timelineQueryData?.group || ''} ${timelineQueryData?.topic || ''} ${ + timelineQueryData?.partition || '' + }` + : `${configDetailQueryData?.service_name || ''} ${ + configDetailQueryData?.topic || '' + } ${configDetailQueryData?.partition || ''}`; + return (
- {isEmptyDetails(timelineQueryData) ? ( + {!validConfigPresent ? (
- Click on a co-ordinate above to see the details + {selectedView === MessagingQueuesViewType.consumerLag.value + ? 'Click on a co-ordinate above to see the details' + : 'Click on a row above to see the details'}
+ ) : isError ? ( +
+ {error?.message || SOMETHING_WENT_WRONG} +
) : ( <> -
- {ConsumerLagDetailTitle[currentTab]} -
{`${timelineQueryData?.group || ''} ${ - timelineQueryData?.topic || '' - } ${timelineQueryData?.partition || ''}`}
-
+ {currentTab && ( +
+ {ConsumerLagDetailTitle[currentTab]} +
{subtitle}
+
+ )}
+ type !== 'Detail' + ? { + onClick: (): void => onRowClick(record), + } + : {} + } + rowClassName={(record): any => + `${record.key}_${selectedView}` === selectedRowKey + ? 'ant-table-row-selected' + : '' + } /> )} diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getConsumerLagDetails.ts b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getConsumerLagDetails.ts index fc6a273e5dd8..20f9c2394bfa 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getConsumerLagDetails.ts +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getConsumerLagDetails.ts @@ -1,19 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; -import { ConsumerLagDetailType } from 'pages/MessagingQueues/MessagingQueuesUtils'; +import { MessagingQueueServiceDetailType } from 'pages/MessagingQueues/MessagingQueuesUtils'; import { ErrorResponse, SuccessResponse } from 'types/api'; -export interface ConsumerLagPayload { +export interface MessagingQueueServicePayload { start?: number | string; end?: number | string; - variables: { + variables?: { partition?: string; topic?: string; consumer_group?: string; + service_name?: string; }; - detailType: ConsumerLagDetailType; + detailType?: MessagingQueueServiceDetailType | 'producer' | 'consumer'; + evalTime?: number; } export interface MessagingQueuesPayloadProps { @@ -36,26 +35,22 @@ export interface MessagingQueuesPayloadProps { } export const getConsumerLagDetails = async ( - props: ConsumerLagPayload, + props: MessagingQueueServicePayload, ): Promise< SuccessResponse | ErrorResponse > => { const { detailType, ...restProps } = props; - try { - const response = await axios.post( - `/messaging-queues/kafka/consumer-lag/${props.detailType}`, - { - ...restProps, - }, - ); + const response = await axios.post( + `/messaging-queues/kafka/consumer-lag/${props.detailType}`, + { + ...restProps, + }, + ); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler((error as AxiosError) || SOMETHING_WENT_WRONG); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getKafkaSpanEval.tsx b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getKafkaSpanEval.tsx new file mode 100644 index 000000000000..1f77faff2d22 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getKafkaSpanEval.tsx @@ -0,0 +1,23 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import { DropRateAPIResponse } from '../DropRateView/dropRateViewUtils'; +import { MessagingQueueServicePayload } from './getConsumerLagDetails'; + +export const getKafkaSpanEval = async ( + props: Omit, +): Promise | ErrorResponse> => { + const { start, end, evalTime } = props; + const response = await axios.post(`messaging-queues/kafka/span/evaluation`, { + start, + end, + eval_time: evalTime, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyDetails.ts b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyDetails.ts new file mode 100644 index 000000000000..1897609aa002 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyDetails.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { MessagingQueueServiceDetailType } from 'pages/MessagingQueues/MessagingQueuesUtils'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import { + MessagingQueueServicePayload, + MessagingQueuesPayloadProps, +} from './getConsumerLagDetails'; + +export const getPartitionLatencyDetails = async ( + props: MessagingQueueServicePayload, +): Promise< + SuccessResponse | ErrorResponse +> => { + const { detailType, ...rest } = props; + let endpoint = ''; + if (detailType === MessagingQueueServiceDetailType.ConsumerDetails) { + endpoint = `/messaging-queues/kafka/partition-latency/consumer`; + } else { + endpoint = `/messaging-queues/kafka/consumer-lag/producer-details`; + } + + const response = await axios.post(endpoint, { + ...rest, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyOverview.ts b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyOverview.ts new file mode 100644 index 000000000000..cdc7fb0cb80c --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getPartitionLatencyOverview.ts @@ -0,0 +1,27 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import { + MessagingQueueServicePayload, + MessagingQueuesPayloadProps, +} from './getConsumerLagDetails'; + +export const getPartitionLatencyOverview = async ( + props: Omit, +): Promise< + SuccessResponse | ErrorResponse +> => { + const response = await axios.post( + `/messaging-queues/kafka/partition-latency/overview`, + { + ...props, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputDetails.ts b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputDetails.ts new file mode 100644 index 000000000000..fb5817abd0be --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputDetails.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import { + MessagingQueueServicePayload, + MessagingQueuesPayloadProps, +} from './getConsumerLagDetails'; + +export const getTopicThroughputDetails = async ( + props: MessagingQueueServicePayload, +): Promise< + SuccessResponse | ErrorResponse +> => { + const { detailType, ...rest } = props; + const endpoint = `/messaging-queues/kafka/topic-throughput/${detailType}`; + const response = await axios.post(endpoint, { + ...rest, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts new file mode 100644 index 000000000000..ac955e84053d --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts @@ -0,0 +1,29 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import { + MessagingQueueServicePayload, + MessagingQueuesPayloadProps, +} from './getConsumerLagDetails'; + +export const getTopicThroughputOverview = async ( + props: Omit, +): Promise< + SuccessResponse | ErrorResponse +> => { + const { detailType, start, end } = props; + const response = await axios.post( + `messaging-queues/kafka/topic-throughput/${detailType}`, + { + start, + end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MessagingQueueOverview.tsx b/frontend/src/pages/MessagingQueues/MQDetails/MessagingQueueOverview.tsx new file mode 100644 index 000000000000..ea55384ac3c0 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MQDetails/MessagingQueueOverview.tsx @@ -0,0 +1,110 @@ +import './MQDetails.style.scss'; + +import { Radio } from 'antd'; +import { Dispatch, SetStateAction } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { + MessagingQueuesViewType, + MessagingQueuesViewTypeOptions, + ProducerLatencyOptions, +} from '../MessagingQueuesUtils'; +import { MessagingQueueServicePayload } from './MQTables/getConsumerLagDetails'; +import { getKafkaSpanEval } from './MQTables/getKafkaSpanEval'; +import { getPartitionLatencyOverview } from './MQTables/getPartitionLatencyOverview'; +import { getTopicThroughputOverview } from './MQTables/getTopicThroughputOverview'; +import MessagingQueuesTable from './MQTables/MQTables'; + +type SelectedViewType = keyof typeof MessagingQueuesViewType; + +function PartitionLatencyTabs({ + option, + setOption, +}: { + option: ProducerLatencyOptions; + setOption: Dispatch>; +}): JSX.Element { + return ( + setOption(e.target.value)} + value={option} + className="mq-details-options" + > + + {ProducerLatencyOptions.Producers} + + + {ProducerLatencyOptions.Consumers} + + + ); +} + +const getTableApi = (selectedView: MessagingQueuesViewTypeOptions): any => { + if (selectedView === MessagingQueuesViewType.producerLatency.value) { + return getTopicThroughputOverview; + } + if (selectedView === MessagingQueuesViewType.dropRate.value) { + return getKafkaSpanEval; + } + return getPartitionLatencyOverview; +}; + +function MessagingQueueOverview({ + selectedView, + option, + setOption, +}: { + selectedView: MessagingQueuesViewTypeOptions; + option: ProducerLatencyOptions; + setOption: Dispatch>; +}): JSX.Element { + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const tableApiPayload: MessagingQueueServicePayload = { + variables: {}, + start: minTime, + end: maxTime, + detailType: + // eslint-disable-next-line no-nested-ternary + selectedView === MessagingQueuesViewType.producerLatency.value + ? option === ProducerLatencyOptions.Producers + ? 'producer' + : 'consumer' + : undefined, + evalTime: + selectedView === MessagingQueuesViewType.dropRate.value + ? 2363404 + : undefined, + }; + + return ( +
+ {selectedView === MessagingQueuesViewType.producerLatency.value ? ( + + ) : ( +
+ {MessagingQueuesViewType[selectedView as SelectedViewType].label} +
+ )} + +
+ ); +} +export default MessagingQueueOverview; diff --git a/frontend/src/pages/MessagingQueues/MessagingQueueHealthCheck/AttributeCheckList.tsx b/frontend/src/pages/MessagingQueues/MessagingQueueHealthCheck/AttributeCheckList.tsx new file mode 100644 index 000000000000..08b2ce6cfad6 --- /dev/null +++ b/frontend/src/pages/MessagingQueues/MessagingQueueHealthCheck/AttributeCheckList.tsx @@ -0,0 +1,270 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './MessagingQueueHealthCheck.styles.scss'; + +import { CaretDownOutlined, LoadingOutlined } from '@ant-design/icons'; +import { + Modal, + Select, + Spin, + Tooltip, + Tree, + TreeDataNode, + Typography, +} from 'antd'; +import { OnboardingStatusResponse } from 'api/messagingQueues/onboarding/getOnboardingStatus'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import { History } from 'history'; +import { Bolt, Check, OctagonAlert, X } from 'lucide-react'; +import { ReactNode, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { isCloudUser } from 'utils/app'; +import { v4 as uuid } from 'uuid'; + +import { + KAFKA_SETUP_DOC_LINK, + MessagingQueueHealthCheckService, +} from '../MessagingQueuesUtils'; + +interface AttributeCheckListProps { + visible: boolean; + onClose: () => void; + onboardingStatusResponses: { + title: string; + data: OnboardingStatusResponse['data']; + errorMsg?: string; + }[]; + loading: boolean; +} + +export enum AttributesFilters { + ALL = 'all', + SUCCESS = 'success', + ERROR = 'error', +} + +function ErrorTitleAndKey({ + title, + parentTitle, + history, + isCloudUserVal, + errorMsg, + isLeaf, +}: { + title: string; + parentTitle: string; + isCloudUserVal: boolean; + history: History; + errorMsg?: string; + isLeaf?: boolean; +}): TreeDataNode { + const handleRedirection = (): void => { + let link = ''; + + switch (parentTitle) { + case 'Consumers': + link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`; + break; + case 'Producers': + link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`; + break; + case 'Kafka': + link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`; + break; + default: + link = ''; + } + + if (isCloudUserVal && !!link) { + history.push(link); + } else { + window.open(KAFKA_SETUP_DOC_LINK, '_blank'); + } + }; + return { + key: `${title}-key-${uuid()}`, + title: ( +
+ + {title} + + +
{ + e.preventDefault(); + handleRedirection(); + }} + > + + Fix +
+
+
+ ), + isLeaf, + }; +} + +function AttributeLabels({ title }: { title: ReactNode }): JSX.Element { + return ( +
+ + {title} +
+ ); +} + +function treeTitleAndKey({ + title, + isLeaf, +}: { + title: string; + isLeaf?: boolean; +}): TreeDataNode { + return { + key: `${title}-key-${uuid()}`, + title: ( +
+ + {title} + + {isLeaf && ( +
+ + + +
+ )} +
+ ), + isLeaf, + }; +} + +function generateTreeDataNodes( + response: OnboardingStatusResponse['data'], + parentTitle: string, + isCloudUserVal: boolean, + history: History, +): TreeDataNode[] { + return response + .map((item) => { + if (item.attribute) { + if (item.status === '1') { + return treeTitleAndKey({ title: item.attribute, isLeaf: true }); + } + if (item.status === '0') { + return ErrorTitleAndKey({ + title: item.attribute, + errorMsg: item.error_message || '', + parentTitle, + history, + isCloudUserVal, + }); + } + } + return null; + }) + .filter(Boolean) as TreeDataNode[]; +} + +function AttributeCheckList({ + visible, + onClose, + onboardingStatusResponses, + loading, +}: AttributeCheckListProps): JSX.Element { + const [filter, setFilter] = useState(AttributesFilters.ALL); + const [treeData, setTreeData] = useState([]); + + const handleFilterChange = (value: AttributesFilters): void => { + setFilter(value); + }; + const isCloudUserVal = isCloudUser(); + const history = useHistory(); + + useEffect(() => { + const filteredData = onboardingStatusResponses.map((response) => { + if (response.errorMsg) { + return ErrorTitleAndKey({ + title: response.title, + errorMsg: response.errorMsg, + isLeaf: true, + parentTitle: response.title, + history, + isCloudUserVal, + }); + } + let filteredData = response.data; + + if (filter === AttributesFilters.SUCCESS) { + filteredData = response.data.filter((item) => item.status === '1'); + } else if (filter === AttributesFilters.ERROR) { + filteredData = response.data.filter((item) => item.status === '0'); + } + + return { + ...treeTitleAndKey({ title: response.title }), + children: generateTreeDataNodes( + filteredData, + response.title, + isCloudUserVal, + history, + ), + }; + }); + + setTreeData(filteredData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter, onboardingStatusResponses]); + + return ( + } + > + {loading ? ( +
+ } size="large" /> +
+ ) : ( +
+