From a50d7f227c92deac51ce29f6291df878670022f8 Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Mon, 14 Nov 2022 14:29:13 +0530 Subject: [PATCH] Feat: dynamic tooltip (#1705) * feat: integrate config service with query service * feat: add tooltip checkpoint * feat: add support for dark and light mode icons Co-authored-by: Palash Gupta --- .../api/dynamicConfigs/getDynamicConfigs.ts | 24 ++++++ frontend/src/container/AppLayout/index.tsx | 32 ++++++++ .../ConfigDropdown/Config/ErrorLink.tsx | 33 ++++++++ .../container/ConfigDropdown/Config/Link.tsx | 23 ++++++ .../container/ConfigDropdown/Config/index.tsx | 51 +++++++++++++ .../src/container/ConfigDropdown/index.tsx | 67 +++++++++++++++++ frontend/src/container/Header/index.tsx | 60 ++++++--------- frontend/src/store/reducers/app.ts | 9 +++ frontend/src/types/actions/app.ts | 10 ++- .../api/dynamicConfigs/getDynamicConfigs.ts | 14 ++++ frontend/src/types/reducer/app.ts | 2 + pkg/query-service/app/http_handler.go | 12 +++ pkg/query-service/constants/constants.go | 2 + .../integrations/signozio/dynamic_config.go | 75 +++++++++++++++++++ .../integrations/signozio/response.go | 54 +++++++++++++ 15 files changed, 429 insertions(+), 39 deletions(-) create mode 100644 frontend/src/api/dynamicConfigs/getDynamicConfigs.ts create mode 100644 frontend/src/container/ConfigDropdown/Config/ErrorLink.tsx create mode 100644 frontend/src/container/ConfigDropdown/Config/Link.tsx create mode 100644 frontend/src/container/ConfigDropdown/Config/index.tsx create mode 100644 frontend/src/container/ConfigDropdown/index.tsx create mode 100644 frontend/src/types/api/dynamicConfigs/getDynamicConfigs.ts create mode 100644 pkg/query-service/integrations/signozio/dynamic_config.go create mode 100644 pkg/query-service/integrations/signozio/response.go diff --git a/frontend/src/api/dynamicConfigs/getDynamicConfigs.ts b/frontend/src/api/dynamicConfigs/getDynamicConfigs.ts new file mode 100644 index 0000000000..149c113119 --- /dev/null +++ b/frontend/src/api/dynamicConfigs/getDynamicConfigs.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/dynamicConfigs/getDynamicConfigs'; + +const getDynamicConfigs = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get(`/configs`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getDynamicConfigs; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 9d3dbb88f7..3ff1cfe6c4 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -1,4 +1,5 @@ import { notification } from 'antd'; +import getDynamicConfigs from 'api/dynamicConfigs/getDynamicConfigs'; import getFeaturesFlags from 'api/features/getFeatureFlags'; import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserVersion from 'api/user/getVersion'; @@ -14,6 +15,7 @@ import { Dispatch } from 'redux'; import { AppState } from 'store/reducers'; import AppActions from 'types/actions'; import { + UPDATE_CONFIGS, UPDATE_CURRENT_ERROR, UPDATE_CURRENT_VERSION, UPDATE_FEATURE_FLAGS, @@ -33,6 +35,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getUserVersionResponse, getUserLatestVersionResponse, getFeaturesResponse, + getDynamicConfigsResponse, ] = useQueries([ { queryFn: getUserVersion, @@ -48,6 +51,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element { queryFn: getFeaturesFlags, queryKey: 'getFeatureFlags', }, + { + queryFn: getDynamicConfigs, + queryKey: 'getDynamicConfigs', + }, ]); useEffect(() => { @@ -65,11 +72,15 @@ function AppLayout(props: AppLayoutProps): JSX.Element { if (getFeaturesResponse.status === 'idle') { getFeaturesResponse.refetch(); } + if (getDynamicConfigsResponse.status === 'idle') { + getDynamicConfigsResponse.refetch(); + } }, [ getFeaturesResponse, getUserLatestVersionResponse, getUserVersionResponse, isLoggedIn, + getDynamicConfigsResponse, ]); const { children } = props; @@ -78,6 +89,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const latestCurrentCounter = useRef(0); const latestVersionCounter = useRef(0); + const latestConfigCounter = useRef(0); useEffect(() => { if ( @@ -170,6 +182,23 @@ function AppLayout(props: AppLayoutProps): JSX.Element { }, }); } + + if ( + getDynamicConfigsResponse.isFetched && + getDynamicConfigsResponse.isSuccess && + getDynamicConfigsResponse.data && + getDynamicConfigsResponse.data.payload && + latestConfigCounter.current === 0 + ) { + latestConfigCounter.current = 1; + + dispatch({ + type: UPDATE_CONFIGS, + payload: { + configs: getDynamicConfigsResponse.data.payload, + }, + }); + } }, [ dispatch, isLoggedIn, @@ -187,6 +216,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getFeaturesResponse.isFetched, getFeaturesResponse.isSuccess, getFeaturesResponse.data, + getDynamicConfigsResponse.data, + getDynamicConfigsResponse.isFetched, + getDynamicConfigsResponse.isSuccess, ]); const isToDisplayLayout = isLoggedIn; diff --git a/frontend/src/container/ConfigDropdown/Config/ErrorLink.tsx b/frontend/src/container/ConfigDropdown/Config/ErrorLink.tsx new file mode 100644 index 0000000000..84ac44e60e --- /dev/null +++ b/frontend/src/container/ConfigDropdown/Config/ErrorLink.tsx @@ -0,0 +1,33 @@ +import React, { PureComponent } from 'react'; + +interface State { + hasError: boolean; +} + +interface Props { + children: JSX.Element; +} + +class ErrorLink extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + render(): JSX.Element { + const { children } = this.props; + const { hasError } = this.state; + + if (hasError) { + return
; + } + + return children; + } +} + +export default ErrorLink; diff --git a/frontend/src/container/ConfigDropdown/Config/Link.tsx b/frontend/src/container/ConfigDropdown/Config/Link.tsx new file mode 100644 index 0000000000..2cc39b7779 --- /dev/null +++ b/frontend/src/container/ConfigDropdown/Config/Link.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +function LinkContainer({ children, href }: LinkContainerProps): JSX.Element { + const isInternalLink = href.startsWith('/'); + + if (isInternalLink) { + return {children}; + } + + return ( + + {children} + + ); +} + +interface LinkContainerProps { + children: React.ReactNode; + href: string; +} + +export default LinkContainer; diff --git a/frontend/src/container/ConfigDropdown/Config/index.tsx b/frontend/src/container/ConfigDropdown/Config/index.tsx new file mode 100644 index 0000000000..956ec5aa00 --- /dev/null +++ b/frontend/src/container/ConfigDropdown/Config/index.tsx @@ -0,0 +1,51 @@ +import { Menu, Space } from 'antd'; +import Spinner from 'components/Spinner'; +import React, { Suspense, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { ConfigProps } from 'types/api/dynamicConfigs/getDynamicConfigs'; +import AppReducer from 'types/reducer/app'; + +import ErrorLink from './ErrorLink'; +import LinkContainer from './Link'; + +function HelpToolTip({ config }: HelpToolTipProps): JSX.Element { + const sortedConfig = useMemo( + () => config.components.sort((a, b) => a.position - b.position), + [config.components], + ); + + const { isDarkMode } = useSelector((state) => state.app); + + return ( + + {sortedConfig.map((item) => { + const iconName = `${isDarkMode ? item.darkIcon : item.lightIcon}`; + + const Component = React.lazy( + () => import(`@ant-design/icons/es/icons/${iconName}.js`), + ); + return ( + + }> + + + + + {item.text} + + + + + + ); + })} + + ); +} + +interface HelpToolTipProps { + config: ConfigProps; +} + +export default HelpToolTip; diff --git a/frontend/src/container/ConfigDropdown/index.tsx b/frontend/src/container/ConfigDropdown/index.tsx new file mode 100644 index 0000000000..65993fc629 --- /dev/null +++ b/frontend/src/container/ConfigDropdown/index.tsx @@ -0,0 +1,67 @@ +import { + CaretDownFilled, + CaretUpFilled, + QuestionCircleFilled, + QuestionCircleOutlined, +} from '@ant-design/icons'; +import { Dropdown, Menu, Space } from 'antd'; +import React, { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import HelpToolTip from './Config'; + +function DynamicConfigDropdown({ + frontendId, +}: DynamicConfigDropdownProps): JSX.Element { + const { configs, isDarkMode } = useSelector( + (state) => state.app, + ); + const [isHelpDropDownOpen, setIsHelpDropDownOpen] = useState(false); + + const config = useMemo( + () => + Object.values(configs).find( + (config) => config.frontendPositionId === frontendId, + ), + [frontendId, configs], + ); + + const onToggleHandler = (): void => { + setIsHelpDropDownOpen(!isHelpDropDownOpen); + }; + + if (!config) { + return
; + } + + const Icon = isDarkMode ? QuestionCircleOutlined : QuestionCircleFilled; + const DropDownIcon = isHelpDropDownOpen ? CaretUpFilled : CaretDownFilled; + + return ( + + + + } + visible={isHelpDropDownOpen} + > + + + + + + ); +} + +interface DynamicConfigDropdownProps { + frontendId: string; +} + +export default DynamicConfigDropdown; diff --git a/frontend/src/container/Header/index.tsx b/frontend/src/container/Header/index.tsx index d6e4f79ae5..4f8bbca048 100644 --- a/frontend/src/container/Header/index.tsx +++ b/frontend/src/container/Header/index.tsx @@ -14,8 +14,9 @@ import { } from 'antd'; import { Logout } from 'api/utils'; import ROUTES from 'constants/routes'; +import Config from 'container/ConfigDropdown'; import setTheme, { AppMode } from 'lib/theme/setTheme'; -import React, { useCallback, useState } from 'react'; +import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { connect, useSelector } from 'react-redux'; import { NavLink } from 'react-router-dom'; import { bindActionCreators } from 'redux'; @@ -34,7 +35,8 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element { const { isDarkMode, user, currentVersion } = useSelector( (state) => state.app, ); - const [isUserDropDownOpen, setIsUserDropDownOpen] = useState(); + + const [isUserDropDownOpen, setIsUserDropDownOpen] = useState(false); const onToggleThemeHandler = useCallback(() => { const preMode: AppMode = isDarkMode ? 'lightMode' : 'darkMode'; @@ -57,22 +59,21 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element { }; }, [toggleDarkMode, isDarkMode]); - const onArrowClickHandler: VoidFunction = () => { - setIsUserDropDownOpen((state) => !state); - }; - - const onClickLogoutHandler = (): void => { - Logout(); - }; + const onToggleHandler = useCallback( + (functionToExecute: Dispatch>) => (): void => { + functionToExecute((state) => !state); + }, + [], + ); const menu = ( - + - + @@ -80,11 +81,11 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element { tabIndex={0} onKeyDown={(e): void => { if (e.key === 'Enter' || e.key === 'Space') { - onClickLogoutHandler(); + Logout(); } }} role="button" - onClick={onClickLogoutHandler} + onClick={Logout} > Logout
@@ -94,23 +95,18 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element { ); return ( - + - + SigNoz SigNoz - + + + + {user?.name[0]} - {!isUserDropDownOpen ? ( - - ) : ( - - )} + {!isUserDropDownOpen ? : } diff --git a/frontend/src/store/reducers/app.ts b/frontend/src/store/reducers/app.ts index 3e18a4c957..6fbc48049d 100644 --- a/frontend/src/store/reducers/app.ts +++ b/frontend/src/store/reducers/app.ts @@ -8,6 +8,7 @@ import { LOGGED_IN, SIDEBAR_COLLAPSE, SWITCH_DARK_MODE, + UPDATE_CONFIGS, UPDATE_CURRENT_ERROR, UPDATE_CURRENT_VERSION, UPDATE_FEATURE_FLAGS, @@ -56,6 +57,7 @@ const InitialValue: InitialValueTypes = { isUserFetchingError: false, org: null, role: null, + configs: {}, }; const appReducer = ( @@ -210,6 +212,13 @@ const appReducer = ( }; } + case UPDATE_CONFIGS: { + return { + ...state, + configs: action.payload.configs, + }; + } + default: return state; } diff --git a/frontend/src/types/actions/app.ts b/frontend/src/types/actions/app.ts index 65264f5ca3..a2a4b90f39 100644 --- a/frontend/src/types/actions/app.ts +++ b/frontend/src/types/actions/app.ts @@ -23,6 +23,7 @@ export const UPDATE_USER = 'UPDATE_USER'; export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME'; export const UPDATE_ORG = 'UPDATE_ORG'; export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS'; +export const UPDATE_CONFIGS = 'UPDATE_CONFIGS'; export interface SwitchDarkMode { type: typeof SWITCH_DARK_MODE; @@ -115,6 +116,12 @@ export interface UpdateOrg { org: AppReducer['org']; }; } +export interface UpdateConfigs { + type: typeof UPDATE_CONFIGS; + payload: { + configs: AppReducer['configs']; + }; +} export type AppAction = | SwitchDarkMode @@ -129,4 +136,5 @@ export type AppAction = | UpdateUser | UpdateOrgName | UpdateOrg - | UpdateFeatureFlags; + | UpdateFeatureFlags + | UpdateConfigs; diff --git a/frontend/src/types/api/dynamicConfigs/getDynamicConfigs.ts b/frontend/src/types/api/dynamicConfigs/getDynamicConfigs.ts new file mode 100644 index 0000000000..69e55e008d --- /dev/null +++ b/frontend/src/types/api/dynamicConfigs/getDynamicConfigs.ts @@ -0,0 +1,14 @@ +export interface ConfigProps { + enabled: boolean; + frontendPositionId: string; + components: Array<{ + href: string; + darkIcon: string; + lightIcon: string; + position: 1; + text: string; + }>; +} +export interface PayloadProps { + [key: string]: ConfigProps; +} diff --git a/frontend/src/types/reducer/app.ts b/frontend/src/types/reducer/app.ts index 5c10f31a83..d95fd3a77b 100644 --- a/frontend/src/types/reducer/app.ts +++ b/frontend/src/types/reducer/app.ts @@ -1,3 +1,4 @@ +import { PayloadProps as ConfigPayload } from 'types/api/dynamicConfigs/getDynamicConfigs'; import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags'; import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization'; import { PayloadProps as UserPayload } from 'types/api/user/getUser'; @@ -26,4 +27,5 @@ export default interface AppReducer { role: ROLES | null; org: OrgPayload | null; featureFlags: null | FeatureFlagPayload; + configs: ConfigPayload; } diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 93c9eff646..18d2743924 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -27,6 +27,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/dao" am "go.signoz.io/signoz/pkg/query-service/integrations/alertManager" + signozio "go.signoz.io/signoz/pkg/query-service/integrations/signozio" "go.signoz.io/signoz/pkg/query-service/interfaces" "go.signoz.io/signoz/pkg/query-service/model" "go.signoz.io/signoz/pkg/query-service/rules" @@ -360,6 +361,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router) { router.HandleFunc("/api/v1/version", OpenAccess(aH.getVersion)).Methods(http.MethodGet) router.HandleFunc("/api/v1/featureFlags", OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/configs", OpenAccess(aH.getConfigs)).Methods(http.MethodGet) router.HandleFunc("/api/v1/getSpanFilters", ViewAccess(aH.getSpanFilters)).Methods(http.MethodPost) router.HandleFunc("/api/v1/getTagFilters", ViewAccess(aH.getTagFilters)).Methods(http.MethodPost) @@ -1583,6 +1585,16 @@ func (aH *APIHandler) CheckFeature(f string) bool { return err == nil } +func (aH *APIHandler) getConfigs(w http.ResponseWriter, r *http.Request) { + + configs, err := signozio.FetchDynamicConfigs() + if err != nil { + aH.HandleError(w, err, http.StatusInternalServerError) + return + } + aH.Respond(w, configs) +} + // inviteUser is used to invite a user. It is used by an admin api. func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) { req, err := parseInviteRequest(r) diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 46b3d4651f..d376b068e9 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -13,6 +13,8 @@ const ( DebugHttpPort = "0.0.0.0:6060" // Address to serve http (pprof) ) +var ConfigSignozIo = "https://config.signoz.io/api/v1" + var DEFAULT_TELEMETRY_ANONYMOUS = false func IsTelemetryEnabled() bool { diff --git a/pkg/query-service/integrations/signozio/dynamic_config.go b/pkg/query-service/integrations/signozio/dynamic_config.go new file mode 100644 index 0000000000..42827b73d5 --- /dev/null +++ b/pkg/query-service/integrations/signozio/dynamic_config.go @@ -0,0 +1,75 @@ +package signozio + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "time" + + "go.signoz.io/signoz/ee/query-service/model" + "go.signoz.io/signoz/pkg/query-service/constants" +) + +var C *Client + +const ( + POST = "POST" + APPLICATION_JSON = "application/json" +) + +type Client struct { + Prefix string +} + +func New() *Client { + return &Client{ + Prefix: constants.ConfigSignozIo, + } +} + +func init() { + C = New() +} + +// FetchDynamicConfigs fetches configs from config server +func FetchDynamicConfigs() (map[string]Config, *model.ApiError) { + + client := http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest(http.MethodGet, C.Prefix+"/configs", http.NoBody) + if err != nil { + return DefaultConfig, nil + } + req.SetBasicAuth("admin", "SigNoz@adm1n") + httpResponse, err := client.Do(req) + if err != nil { + return DefaultConfig, nil + } + + defer httpResponse.Body.Close() + + if err != nil { + return DefaultConfig, nil + } + + httpBody, err := ioutil.ReadAll(httpResponse.Body) + if err != nil { + return DefaultConfig, nil + } + + // read api request result + result := ConfigResult{} + err = json.Unmarshal(httpBody, &result) + if err != nil { + return DefaultConfig, nil + } + + switch httpResponse.StatusCode { + case 200, 201: + return result.Data, nil + case 400, 401: + return DefaultConfig, nil + default: + return DefaultConfig, nil + } + +} diff --git a/pkg/query-service/integrations/signozio/response.go b/pkg/query-service/integrations/signozio/response.go new file mode 100644 index 0000000000..8440346ec4 --- /dev/null +++ b/pkg/query-service/integrations/signozio/response.go @@ -0,0 +1,54 @@ +package signozio + +type status string + +type ConfigResult struct { + Status status `json:"status"` + Data map[string]Config `json:"data,omitempty"` + ErrorType string `json:"errorType,omitempty"` + Error string `json:"error,omitempty"` +} + +type Config struct { + Enabled bool `json:"enabled"` + FrontendPositionId string `json:"frontendPositionId"` + Components []ComponentProps `json:"components"` +} + +type ComponentProps struct { + Text string `json:"text"` + Position int `json:"position"` + DarkIcon string `json:"darkIcon"` + LightIcon string `json:"lightIcon"` + Href string `json:"href"` +} + +var DefaultConfig = map[string]Config{ + "helpConfig": { + Enabled: true, + FrontendPositionId: "tooltip", + Components: []ComponentProps{ + { + Text: "How to use SigNoz in production", + Position: 1, + LightIcon: "RiseOutlined", + DarkIcon: "RiseOutlined", + Href: "https://signoz.io/docs/production-readiness", + }, + { + Text: "Create an issue in GitHub", + Position: 2, + LightIcon: "GithubFilled", + DarkIcon: "GithubOutlined", + Href: "https://github.com/SigNoz/signoz/issues/new/choose", + }, + { + Text: "Read the docs", + Position: 3, + LightIcon: "FileTextFilled", + DarkIcon: "FileTextOutlined", + Href: "https://signoz.io/docs", + }, + }, + }, +}