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 { notification } from 'antd';
import getDynamicConfigs from 'api/dynamicConfigs/getDynamicConfigs';
import getFeaturesFlags from 'api/features/getFeatureFlags'; import getFeaturesFlags from 'api/features/getFeatureFlags';
import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserLatestVersion from 'api/user/getLatestVersion';
import getUserVersion from 'api/user/getVersion'; import getUserVersion from 'api/user/getVersion';
@ -14,6 +15,7 @@ import { Dispatch } from 'redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { import {
UPDATE_CONFIGS,
UPDATE_CURRENT_ERROR, UPDATE_CURRENT_ERROR,
UPDATE_CURRENT_VERSION, UPDATE_CURRENT_VERSION,
UPDATE_FEATURE_FLAGS, UPDATE_FEATURE_FLAGS,
@ -33,6 +35,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
getUserVersionResponse, getUserVersionResponse,
getUserLatestVersionResponse, getUserLatestVersionResponse,
getFeaturesResponse, getFeaturesResponse,
getDynamicConfigsResponse,
] = useQueries([ ] = useQueries([
{ {
queryFn: getUserVersion, queryFn: getUserVersion,
@ -48,6 +51,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
queryFn: getFeaturesFlags, queryFn: getFeaturesFlags,
queryKey: 'getFeatureFlags', queryKey: 'getFeatureFlags',
}, },
{
queryFn: getDynamicConfigs,
queryKey: 'getDynamicConfigs',
},
]); ]);
useEffect(() => { useEffect(() => {
@ -65,11 +72,15 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
if (getFeaturesResponse.status === 'idle') { if (getFeaturesResponse.status === 'idle') {
getFeaturesResponse.refetch(); getFeaturesResponse.refetch();
} }
if (getDynamicConfigsResponse.status === 'idle') {
getDynamicConfigsResponse.refetch();
}
}, [ }, [
getFeaturesResponse, getFeaturesResponse,
getUserLatestVersionResponse, getUserLatestVersionResponse,
getUserVersionResponse, getUserVersionResponse,
isLoggedIn, isLoggedIn,
getDynamicConfigsResponse,
]); ]);
const { children } = props; const { children } = props;
@ -78,6 +89,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const latestCurrentCounter = useRef(0); const latestCurrentCounter = useRef(0);
const latestVersionCounter = useRef(0); const latestVersionCounter = useRef(0);
const latestConfigCounter = useRef(0);
useEffect(() => { useEffect(() => {
if ( 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, dispatch,
isLoggedIn, isLoggedIn,
@ -187,6 +216,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
getFeaturesResponse.isFetched, getFeaturesResponse.isFetched,
getFeaturesResponse.isSuccess, getFeaturesResponse.isSuccess,
getFeaturesResponse.data, getFeaturesResponse.data,
getDynamicConfigsResponse.data,
getDynamicConfigsResponse.isFetched,
getDynamicConfigsResponse.isSuccess,
]); ]);
const isToDisplayLayout = isLoggedIn; 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'; } from 'antd';
import { Logout } from 'api/utils'; import { Logout } from 'api/utils';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import Config from 'container/ConfigDropdown';
import setTheme, { AppMode } from 'lib/theme/setTheme'; 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 { connect, useSelector } from 'react-redux';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
@ -34,7 +35,8 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
const { isDarkMode, user, currentVersion } = useSelector<AppState, AppReducer>( const { isDarkMode, user, currentVersion } = useSelector<AppState, AppReducer>(
(state) => state.app, (state) => state.app,
); );
const [isUserDropDownOpen, setIsUserDropDownOpen] = useState<boolean>();
const [isUserDropDownOpen, setIsUserDropDownOpen] = useState<boolean>(false);
const onToggleThemeHandler = useCallback(() => { const onToggleThemeHandler = useCallback(() => {
const preMode: AppMode = isDarkMode ? 'lightMode' : 'darkMode'; const preMode: AppMode = isDarkMode ? 'lightMode' : 'darkMode';
@ -57,22 +59,21 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
}; };
}, [toggleDarkMode, isDarkMode]); }, [toggleDarkMode, isDarkMode]);
const onArrowClickHandler: VoidFunction = () => { const onToggleHandler = useCallback(
setIsUserDropDownOpen((state) => !state); (functionToExecute: Dispatch<SetStateAction<boolean>>) => (): void => {
}; functionToExecute((state) => !state);
},
const onClickLogoutHandler = (): void => { [],
Logout(); );
};
const menu = ( const menu = (
<Menu style={{ padding: '1rem' }}> <Menu style={{ padding: '1rem' }}>
<Menu.ItemGroup> <Menu.ItemGroup>
<SignedInAS /> <SignedInAS />
<Divider /> <Divider />
<CurrentOrganization onToggle={onArrowClickHandler} /> <CurrentOrganization onToggle={onToggleHandler(setIsUserDropDownOpen)} />
<Divider /> <Divider />
<ManageLicense onToggle={onArrowClickHandler} /> <ManageLicense onToggle={onToggleHandler(setIsUserDropDownOpen)} />
<Divider /> <Divider />
<LogoutContainer> <LogoutContainer>
<LogoutOutlined /> <LogoutOutlined />
@ -80,11 +81,11 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
tabIndex={0} tabIndex={0}
onKeyDown={(e): void => { onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === 'Space') { if (e.key === 'Enter' || e.key === 'Space') {
onClickLogoutHandler(); Logout();
} }
}} }}
role="button" role="button"
onClick={onClickLogoutHandler} onClick={Logout}
> >
<Typography.Link>Logout</Typography.Link> <Typography.Link>Logout</Typography.Link>
</div> </div>
@ -94,23 +95,18 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
); );
return ( return (
<Layout.Header <Layout.Header>
style={{
paddingLeft: '1.125rem',
paddingRight: '1.125rem',
}}
>
<Container> <Container>
<NavLink <NavLink to={ROUTES.APPLICATION}>
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
to={ROUTES.APPLICATION}
>
<img src={`/signoz.svg?currentVersion=${currentVersion}`} alt="SigNoz" /> <img src={`/signoz.svg?currentVersion=${currentVersion}`} alt="SigNoz" />
<Typography.Title style={{ margin: 0, color: '#DBDBDB' }} level={4}> <Typography.Title style={{ margin: 0, color: '#DBDBDB' }} level={4}>
SigNoz SigNoz
</Typography.Title> </Typography.Title>
</NavLink> </NavLink>
<Space align="center">
<Space style={{ height: '100%' }} align="center">
<Config frontendId="tooltip" />
<ToggleButton <ToggleButton
checked={isDarkMode} checked={isDarkMode}
onChange={onToggleThemeHandler} onChange={onToggleThemeHandler}
@ -120,26 +116,14 @@ function HeaderContainer({ toggleDarkMode }: Props): JSX.Element {
/> />
<Dropdown <Dropdown
onVisibleChange={onArrowClickHandler} onVisibleChange={onToggleHandler(setIsUserDropDownOpen)}
trigger={['click']} trigger={['click']}
overlay={menu} overlay={menu}
visible={isUserDropDownOpen} visible={isUserDropDownOpen}
> >
<Space> <Space>
<Avatar shape="circle">{user?.name[0]}</Avatar> <Avatar shape="circle">{user?.name[0]}</Avatar>
{!isUserDropDownOpen ? ( {!isUserDropDownOpen ? <CaretDownFilled /> : <CaretUpFilled />}
<CaretDownFilled
style={{
color: '#DBDBDB',
}}
/>
) : (
<CaretUpFilled
style={{
color: '#DBDBDB',
}}
/>
)}
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>

View File

@ -8,6 +8,7 @@ import {
LOGGED_IN, LOGGED_IN,
SIDEBAR_COLLAPSE, SIDEBAR_COLLAPSE,
SWITCH_DARK_MODE, SWITCH_DARK_MODE,
UPDATE_CONFIGS,
UPDATE_CURRENT_ERROR, UPDATE_CURRENT_ERROR,
UPDATE_CURRENT_VERSION, UPDATE_CURRENT_VERSION,
UPDATE_FEATURE_FLAGS, UPDATE_FEATURE_FLAGS,
@ -56,6 +57,7 @@ const InitialValue: InitialValueTypes = {
isUserFetchingError: false, isUserFetchingError: false,
org: null, org: null,
role: null, role: null,
configs: {},
}; };
const appReducer = ( const appReducer = (
@ -210,6 +212,13 @@ const appReducer = (
}; };
} }
case UPDATE_CONFIGS: {
return {
...state,
configs: action.payload.configs,
};
}
default: default:
return state; 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_NAME = 'UPDATE_ORG_NAME';
export const UPDATE_ORG = 'UPDATE_ORG'; export const UPDATE_ORG = 'UPDATE_ORG';
export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS'; export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS';
export const UPDATE_CONFIGS = 'UPDATE_CONFIGS';
export interface SwitchDarkMode { export interface SwitchDarkMode {
type: typeof SWITCH_DARK_MODE; type: typeof SWITCH_DARK_MODE;
@ -115,6 +116,12 @@ export interface UpdateOrg {
org: AppReducer['org']; org: AppReducer['org'];
}; };
} }
export interface UpdateConfigs {
type: typeof UPDATE_CONFIGS;
payload: {
configs: AppReducer['configs'];
};
}
export type AppAction = export type AppAction =
| SwitchDarkMode | SwitchDarkMode
@ -129,4 +136,5 @@ export type AppAction =
| UpdateUser | UpdateUser
| UpdateOrgName | UpdateOrgName
| UpdateOrg | 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 FeatureFlagPayload } from 'types/api/features/getFeaturesFlags';
import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization'; import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization';
import { PayloadProps as UserPayload } from 'types/api/user/getUser'; import { PayloadProps as UserPayload } from 'types/api/user/getUser';
@ -26,4 +27,5 @@ export default interface AppReducer {
role: ROLES | null; role: ROLES | null;
org: OrgPayload | null; org: OrgPayload | null;
featureFlags: null | FeatureFlagPayload; featureFlags: null | FeatureFlagPayload;
configs: ConfigPayload;
} }

View File

@ -27,6 +27,7 @@ import (
"go.signoz.io/signoz/pkg/query-service/dao" "go.signoz.io/signoz/pkg/query-service/dao"
am "go.signoz.io/signoz/pkg/query-service/integrations/alertManager" 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/interfaces"
"go.signoz.io/signoz/pkg/query-service/model" "go.signoz.io/signoz/pkg/query-service/model"
"go.signoz.io/signoz/pkg/query-service/rules" "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/version", OpenAccess(aH.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/featureFlags", OpenAccess(aH.getFeatureFlags)).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/getSpanFilters", ViewAccess(aH.getSpanFilters)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/getTagFilters", ViewAccess(aH.getTagFilters)).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 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. // inviteUser is used to invite a user. It is used by an admin api.
func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) { func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) {
req, err := parseInviteRequest(r) req, err := parseInviteRequest(r)

View File

@ -13,6 +13,8 @@ const (
DebugHttpPort = "0.0.0.0:6060" // Address to serve http (pprof) DebugHttpPort = "0.0.0.0:6060" // Address to serve http (pprof)
) )
var ConfigSignozIo = "https://config.signoz.io/api/v1"
var DEFAULT_TELEMETRY_ANONYMOUS = false var DEFAULT_TELEMETRY_ANONYMOUS = false
func IsTelemetryEnabled() bool { 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",
},
},
},
}