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 <palashgdev@gmail.com>
This commit is contained in:
Vishal Sharma 2022-11-14 14:29:13 +05:30 committed by GitHub
parent 73706d872f
commit a50d7f227c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 429 additions and 39 deletions

View File

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

View File

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

View File

@ -0,0 +1,33 @@
import React, { PureComponent } from 'react';
interface State {
hasError: boolean;
}
interface Props {
children: JSX.Element;
}
class ErrorLink extends PureComponent<Props, State> {
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 <div />;
}
return children;
}
}
export default ErrorLink;

View File

@ -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 <Link to={href}>{children}</Link>;
}
return (
<a rel="noreferrer" target="_blank" href={href}>
{children}
</a>
);
}
interface LinkContainerProps {
children: React.ReactNode;
href: string;
}
export default LinkContainer;

View File

@ -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<AppState, AppReducer>((state) => state.app);
return (
<Menu.ItemGroup>
{sortedConfig.map((item) => {
const iconName = `${isDarkMode ? item.darkIcon : item.lightIcon}`;
const Component = React.lazy(
() => import(`@ant-design/icons/es/icons/${iconName}.js`),
);
return (
<ErrorLink key={item.text + item.href}>
<Suspense fallback={<Spinner height="5vh" />}>
<Menu.Item>
<LinkContainer href={item.href}>
<Space size="small" align="start">
<Component />
{item.text}
</Space>
</LinkContainer>
</Menu.Item>
</Suspense>
</ErrorLink>
);
})}
</Menu.ItemGroup>
);
}
interface HelpToolTipProps {
config: ConfigProps;
}
export default HelpToolTip;

View File

@ -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<AppState, AppReducer>(
(state) => state.app,
);
const [isHelpDropDownOpen, setIsHelpDropDownOpen] = useState<boolean>(false);
const config = useMemo(
() =>
Object.values(configs).find(
(config) => config.frontendPositionId === frontendId,
),
[frontendId, configs],
);
const onToggleHandler = (): void => {
setIsHelpDropDownOpen(!isHelpDropDownOpen);
};
if (!config) {
return <div />;
}
const Icon = isDarkMode ? QuestionCircleOutlined : QuestionCircleFilled;
const DropDownIcon = isHelpDropDownOpen ? CaretUpFilled : CaretDownFilled;
return (
<Dropdown
onVisibleChange={onToggleHandler}
trigger={['click']}
overlay={
<Menu>
<HelpToolTip config={config} />
</Menu>
}
visible={isHelpDropDownOpen}
>
<Space align="center">
<Icon
style={{ fontSize: 26, color: 'white', paddingTop: 20, cursor: 'pointer' }}
/>
<DropDownIcon style={{ color: 'white' }} />
</Space>
</Dropdown>
);
}
interface DynamicConfigDropdownProps {
frontendId: string;
}
export default DynamicConfigDropdown;

View File

@ -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<AppState, AppReducer>(
(state) => state.app,
);
const [isUserDropDownOpen, setIsUserDropDownOpen] = useState<boolean>();
const [isUserDropDownOpen, setIsUserDropDownOpen] = useState<boolean>(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<SetStateAction<boolean>>) => (): void => {
functionToExecute((state) => !state);
},
[],
);
const menu = (
<Menu style={{ padding: '1rem' }}>
<Menu.ItemGroup>
<SignedInAS />
<Divider />
<CurrentOrganization onToggle={onArrowClickHandler} />
<CurrentOrganization onToggle={onToggleHandler(setIsUserDropDownOpen)} />
<Divider />
<ManageLicense onToggle={onArrowClickHandler} />
<ManageLicense onToggle={onToggleHandler(setIsUserDropDownOpen)} />
<Divider />
<LogoutContainer>
<LogoutOutlined />
@ -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}
>
<Typography.Link>Logout</Typography.Link>
</div>
@ -94,23 +95,18 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
);
return (
<Layout.Header
style={{
paddingLeft: '1.125rem',
paddingRight: '1.125rem',
}}
>
<Layout.Header>
<Container>
<NavLink
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
to={ROUTES.APPLICATION}
>
<NavLink to={ROUTES.APPLICATION}>
<img src={`/signoz.svg?currentVersion=${currentVersion}`} alt="SigNoz" />
<Typography.Title style={{ margin: 0, color: '#DBDBDB' }} level={4}>
SigNoz
</Typography.Title>
</NavLink>
<Space align="center">
<Space style={{ height: '100%' }} align="center">
<Config frontendId="tooltip" />
<ToggleButton
checked={isDarkMode}
onChange={onToggleThemeHandler}
@ -120,26 +116,14 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
/>
<Dropdown
onVisibleChange={onArrowClickHandler}
onVisibleChange={onToggleHandler(setIsUserDropDownOpen)}
trigger={['click']}
overlay={menu}
visible={isUserDropDownOpen}
>
<Space>
<Avatar shape="circle">{user?.name[0]}</Avatar>
{!isUserDropDownOpen ? (
<CaretDownFilled
style={{
color: '#DBDBDB',
}}
/>
) : (
<CaretUpFilled
style={{
color: '#DBDBDB',
}}
/>
)}
{!isUserDropDownOpen ? <CaretDownFilled /> : <CaretUpFilled />}
</Space>
</Dropdown>
</Space>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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