Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi kumar
ed17003329 fix: y-axis crosshair sync for tooltip (#10944)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: added changes for crosshair sync for tooltip

* chore: minor cleanup

* chore: updated the core structure

* chore: updated the types

* chore: minor cleanup

* chore: minor changes
2026-04-21 08:36:04 +00:00
46 changed files with 160 additions and 533 deletions

View File

@@ -66,8 +66,6 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -1,153 +0,0 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -60,7 +59,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -137,7 +136,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="css/uPlot.min.css" />
<link rel="stylesheet" href="/css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -2,7 +2,6 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -25,7 +24,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
return `/locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

@@ -1,6 +1,5 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -18,7 +17,6 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,7 +11,6 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -68,39 +67,6 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -167,7 +133,6 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -182,7 +147,6 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -194,7 +158,6 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -207,7 +170,6 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -220,7 +182,6 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -233,7 +194,6 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -241,7 +201,6 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -12,7 +12,6 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -134,7 +133,7 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
},
[
prepareQuery,

View File

@@ -13,7 +13,6 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -81,13 +80,17 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
}

View File

@@ -9,7 +9,6 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -95,14 +94,20 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
},
className: 'clickable-row',
})}

View File

@@ -14,7 +14,6 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -388,7 +387,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -30,6 +30,7 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -62,6 +63,13 @@ export default function ChartWrapper({
[customTooltip],
);
const syncMetadata = useMemo(
() => ({
yAxisUnit,
}),
[yAxisUnit],
);
return (
<PlotContextProvider>
<ChartLayout
@@ -99,6 +107,7 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -24,13 +24,12 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -12,10 +12,7 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -32,18 +29,31 @@ interface UPlotBasedChartProps {
layoutChildren?: React.ReactNode;
}
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {}
UPlotBasedChartProps,
UPlotChartDataProps {
timezone?: Timezone;
}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps {
UPlotBasedChartProps,
UPlotChartDataProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
export interface BarChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =

View File

@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
>
<ContextMenu

View File

@@ -3,8 +3,6 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -29,7 +27,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const config = useMemo(() => {
return prepareHistogramPanelConfig({
@@ -92,11 +89,9 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
isQueriesMerged={widget.mergeAllActiveQueries}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -48,8 +48,8 @@ jest.mock(
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
yAxisUnit: props?.yAxisUnit,
decimalPrecision: props?.decimalPrecision,
})}
</div>
{props.layoutChildren}

View File

@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -15,7 +15,6 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -88,7 +87,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -83,8 +83,6 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -459,7 +457,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(getLink());
window.open(getLink(), '_blank');
}}
>
Open in New Tab
@@ -471,7 +469,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(getLink()));
setCopy(`${window.location.origin}${getLink()}`);
}}
>
Copy Link

View File

@@ -1,7 +1,6 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -13,7 +12,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
window.open(getLink(), '_blank');
} else {
history.push(getLink());
}

View File

@@ -17,7 +17,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -117,7 +116,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');
window.open(link, '_blank', 'noopener,noreferrer');
},
[query, urlQuery],
);

View File

@@ -34,7 +34,6 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -192,7 +191,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
openInNewTab(route);
window.open(route, '_blank');
} else {
history.push(route);
}

View File

@@ -34,7 +34,6 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -240,7 +239,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,6 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -38,7 +37,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');
window.open(newTraceExplorerPath, '_blank');
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -14,7 +14,6 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -116,7 +115,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
openInNewTab(url);
window.open(url, '_blank');
onClose?.();
}}
>

View File

@@ -14,7 +14,6 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -77,7 +76,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
openInNewTab(ROUTES.CHANNELS_NEW);
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.

View File

@@ -28,7 +28,6 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -144,7 +143,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
openInNewTab(url);
window.open(url, '_blank');
},
[
isLogSpanRelated,

View File

@@ -17,7 +17,6 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -159,7 +158,9 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);

View File

@@ -31,7 +31,6 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -215,7 +214,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink(record));
window.open(getLink(record), '_blank');
} else {
history.push(getLink(record));
}

View File

@@ -28,7 +28,6 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -87,7 +86,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
openInNewTab(getTraceLink(record));
window.open(getTraceLink(record), '_blank');
} else {
history.push(getTraceLink(record));
}

View File

@@ -17,7 +17,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -61,7 +60,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);

View File

@@ -4,7 +4,6 @@ import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
export const useCopySpanLink = (
span?: Span,
@@ -29,7 +28,7 @@ export const useCopySpanLink = (
urlQuery.set('spanId', span?.spanId);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
notifications.success({

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -131,7 +130,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(withBasePath(targetPath), '_blank');
window.open(targetPath, '_blank');
return;
}

View File

@@ -1,4 +1,3 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory({ basename: getBasePath() });
export default createBrowserHistory();

View File

@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone?: Timezone;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
timezone?: Timezone;
}
export interface TimeSeriesTooltipProps

View File

@@ -4,6 +4,7 @@ import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
createInitialControllerState,
createSetCursorHandler,
@@ -40,6 +41,7 @@ export default function TooltipPlugin({
maxHeight = 600,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
@@ -100,7 +102,29 @@ export default function TooltipPlugin({
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', null] },
sync: { key: syncKey, scales: ['x', 'y'] },
});
// Show the horizontal crosshair only when the receiving panel shares
// the same y-axis unit as the source panel. When this panel is the
// source (cursor.event != null) the line is always shown and this
// panel's metadata is written to the registry so receivers can read it.
config.addHook('setCursor', (u: uPlot): void => {
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!yCursorEl) {
return;
}
if (u.cursor.event != null) {
// This panel is the source — publish metadata and always show line.
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
yCursorEl.style.display = '';
} else {
// This panel is receiving sync — show only if units match.
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
yCursorEl.style.display =
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
}
});
}

View File

@@ -0,0 +1,24 @@
import type { TooltipSyncMetadata } from './types';
/**
* Module-level registry that tracks the metadata of the panel currently
* acting as the cursor source (the one being hovered) per sync group.
*
* uPlot fires the source panel's setCursor hook before broadcasting to
* receivers, so the registry is always populated before receivers read it.
*
* Receivers use this to make decisions such as:
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
* - Future: what to render inside the tooltip (matching groupBy, etc.)
*/
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
export const syncCursorRegistry = {
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
metadataBySyncKey.set(syncKey, metadata);
},
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
return metadataBySyncKey.get(syncKey);
},
};

View File

@@ -34,11 +34,16 @@ export interface TooltipLayoutInfo {
height: number;
}
export interface TooltipSyncMetadata {
yAxisUnit?: string;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;

View File

@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', null] },
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
});
});

View File

@@ -4,7 +4,6 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -12,8 +11,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
// Go to home page
window.location.href = ROUTES.HOME;
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,7 +19,6 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -94,7 +93,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
openInNewTab(`${ROUTES.TRACE}/${item}`);
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -124,7 +123,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
openInNewTab(`/services/${encodeURIComponent(text)}`);
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
}}
>
{text}

View File

@@ -1,118 +0,0 @@
/**
* basePath is memoized at module init, so each describe block isolates the
* module with a fresh DOM state using jest.isolateModules + require.
*/
type BasePath = typeof import('../basePath');
function loadModule(href?: string): BasePath {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: BasePath;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../basePath');
});
return mod;
}
afterEach(() => {
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('at basePath="/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/');
});
it('getBasePath returns "/"', () => {
expect(m.getBasePath()).toBe('/');
});
it('withBasePath is a no-op for any internal path', () => {
expect(m.withBasePath('/logs')).toBe('/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
});
describe('at basePath="/signoz/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/signoz/');
});
it('getBasePath returns "/signoz/"', () => {
expect(m.getBasePath()).toBe('/signoz/');
});
it('withBasePath prepends the prefix', () => {
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
});
it('withBasePath is idempotent — safe to call twice', () => {
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
});
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
expect(m.withBasePath('/signoz')).toBe('/signoz');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,
);
});
it('getBaseUrl returns origin + prefix without trailing slash', () => {
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
});
});
describe('no <base> tag', () => {
it('getBasePath falls back to "/"', () => {
const m = loadModule();
expect(m.getBasePath()).toBe('/');
});
});
describe('href without trailing slash', () => {
it('normalises to trailing slash', () => {
const m = loadModule('/signoz');
expect(m.getBasePath()).toBe('/signoz/');
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
});
});
describe('nested prefix "/a/b/prefix/"', () => {
it('withBasePath handles arbitrary depth', () => {
const m = loadModule('/a/b/prefix/');
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
});
});

View File

@@ -1,27 +1,15 @@
import { isModifierKeyPressed } from '../app';
type NavigationModule = typeof import('../navigation');
function loadNavigationModule(href?: string): NavigationModule {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: NavigationModule;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../navigation');
});
return mod;
}
import { openInNewTab } from '../navigation';
describe('navigation utilities', () => {
const originalWindowOpen = window.open;
beforeEach(() => {
window.open = jest.fn();
});
afterEach(() => {
window.open = originalWindowOpen;
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('isModifierKeyPressed', () => {
@@ -68,59 +56,25 @@ describe('navigation utilities', () => {
});
describe('openInNewTab', () => {
describe('at basePath="/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/');
});
it('passes internal path through unchanged', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('handles paths with query strings', () => {
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
it('calls window.open with the given path and _blank target', () => {
openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
describe('at basePath="/signoz/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/signoz/');
});
it('handles full URLs', () => {
openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('prepends base path to internal paths', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('is idempotent — does not double-prefix an already-prefixed path', () => {
m.openInNewTab('/signoz/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('handles paths with query strings', () => {
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
});
});

View File

@@ -1,50 +0,0 @@
// Read once at module init — avoids a DOM query on every axios request.
const _basePath: string = ((): string => {
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
return href.endsWith('/') ? href : `${href}/`;
})();
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
export function getBasePath(): string {
return _basePath;
}
/**
* Prepends the base path to an internal absolute path.
* Idempotent and safe to call on any value.
*
* withBasePath('/logs') → '/signoz/logs'
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
*/
export function withBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
return path;
}
return _basePath + path.slice(1);
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
*/
export function getAbsoluteUrl(path: string): string {
return window.location.origin + withBasePath(path);
}
/**
* Origin + base path without trailing slash — for sending to the backend
* as frontendBaseUrl in invite / password-reset email flows.
* getBaseUrl() → 'https://host/signoz'
*/
export function getBaseUrl(): string {
return (
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
);
}

View File

@@ -1,5 +1,6 @@
import { withBasePath } from 'utils/basePath';
/**
* Opens the given path in a new browser tab.
*/
export const openInNewTab = (path: string): void => {
window.open(withBasePath(path), '_blank');
window.open(path, '_blank');
};

View File

@@ -10,18 +10,6 @@ import { createHtmlPlugin } from 'vite-plugin-html';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths';
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
// with "/" so relative assets resolve correctly from the Vite dev server.
function devBasePathPlugin(): Plugin {
return {
name: 'dev-base-path',
apply: 'serve',
transformIndexHtml(html): string {
return html.replaceAll('[[.BaseHref]]', '/');
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -44,7 +32,6 @@ export default defineConfig(
const plugins = [
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -137,7 +124,6 @@ export default defineConfig(
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
},
base: './',
build: {
sourcemap: true,
outDir: 'build',