Compare commits

..

2 Commits

Author SHA1 Message Date
Abhi kumar
d5dcdf382c chore: added changes for pinning tooltip with a shortcut key (#10953)
Some checks are pending
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
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: added changes for pinning tooltip with a shortcut key

* chore: updated tooltipplugin tests

* chore: added support for onclick in tooltip

* chore: fixed minor issue

* chore: updated tooltip pinning desings

* chore: minor changes

* chore: updated failing test

* chore: updated pr review changes

* chore: fixed tooltip tests

* chore: fixed module css issues

* chore: pr review fixes

* chore: replaced kbd component with component from signozui

* chore: updated the tokens

* chore: updated tokens

* chore: updated pinned color

* chore: updated footer styles

* chore: fixed linter issue
2026-04-22 17:37:38 +00:00
aniketio-ctrl
ce5e3e7943 feat(billing): increase zeus http client timeout (#11061)
* feat(billing): add zeus put meters api

* feat(billing): add zeus put meters api

* feat(billing): increase zeuss http client timeour
2026-04-22 17:27:39 +00:00
38 changed files with 942 additions and 836 deletions

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -37,6 +38,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
client.WithTimeout(30*time.Second),
)
if err != nil {
return nil, err

View File

@@ -52,7 +52,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.9",
"@signozhq/ui": "0.0.10",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -266,4 +266,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -1,126 +0,0 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
initialExpression?: string;
/**
* @default false
*/
persistOnUnmount?: boolean;
children: ReactNode;
}
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
persistOnUnmount = false,
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
queryParamKey,
parseAsString,
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);
}

View File

@@ -1,60 +0,0 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* User-typed expression (local state, updates on typing)
*/
inputExpression: string;
/**
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
inputExpression: '',
committedExpression: '',
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},
commitExpression: (expression: string): void => {
set({
inputExpression: expression,
committedExpression: expression,
});
},
resetExpression: (): void => {
set({
inputExpression: '',
committedExpression: '',
});
},
initializeFromUrl: (urlExpression: string): void => {
set({
inputExpression: urlExpression,
committedExpression: urlExpression,
});
},
}));
}

View File

@@ -1,95 +0,0 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
} from '../QuerySearchV2.provider';
const mockSetQueryState = jest.fn();
let mockUrlValue: string | null = null;
jest.mock('nuqs', () => ({
parseAsString: {},
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
}));
function createWrapper(
props: Partial<QuerySearchV2ProviderProps> = {},
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
{children}
</QuerySearchV2Provider>
);
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('');
expect(result.current.userExpression).toBe('');
expect(result.current.initialExpression).toBe('');
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
'k8s.pod.name = "my-pod" AND (service = "api")',
);
expect(result.current.userExpression).toBe('service = "api"');
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.userExpression).toBe('status = 500');
expect(result.current.expression).toBe('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -1,61 +0,0 @@
import { createExpressionStore } from '../QuerySearchV2.store';
describe('createExpressionStore', () => {
it('should create a store with initial state', () => {
const store = createExpressionStore();
const state = store.getState();
expect(state.inputExpression).toBe('');
expect(state.committedExpression).toBe('');
});
it('should update inputExpression via setInputExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('');
});
it('should update both expressions via commitExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('service.name = "api"');
});
it('should reset all state via resetExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
store.getState().resetExpression();
expect(store.getState().inputExpression).toBe('');
expect(store.getState().committedExpression).toBe('');
});
it('should initialize from URL value', () => {
const store = createExpressionStore();
store.getState().initializeFromUrl('status = 500');
expect(store.getState().inputExpression).toBe('status = 500');
expect(store.getState().committedExpression).toBe('status = 500');
});
it('should create isolated store instances', () => {
const store1 = createExpressionStore();
const store2 = createExpressionStore();
store1.getState().setInputExpression('expr1');
store2.getState().setInputExpression('expr2');
expect(store1.getState().inputExpression).toBe('expr1');
expect(store2.getState().inputExpression).toBe('expr2');
});
});

View File

@@ -1,18 +0,0 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context = createContext<QuerySearchV2ContextValue | null>(
null,
);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return context;
}

View File

@@ -1,8 +0,0 @@
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -13,13 +13,6 @@
display: flex;
flex-direction: row;
.query-search-initial-scope-label {
position: absolute;
left: 8px;
top: 10px;
z-index: 10;
}
.query-where-clause-editor {
flex: 1;
min-width: 400px;
@@ -54,10 +47,6 @@
}
}
}
&.hasInitialExpression .cm-editor .cm-content {
padding-left: 22px !important;
}
}
.cm-editor {
@@ -73,6 +62,7 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
padding: 0px !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--l1-border);

View File

@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Filter, Info, TriangleAlert } from 'lucide-react';
import { Info, TriangleAlert } from 'lucide-react';
import {
IDetailedError,
IQueryContext,
@@ -47,7 +47,6 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -86,8 +85,6 @@ interface QuerySearchProps {
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
}
function QuerySearch({
@@ -99,7 +96,6 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -116,26 +112,18 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
const toValidate = isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
: editorDoc;
try {
const validationResponse = validateQuery(toValidate);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
},
[initialExpression, isScopedFilter],
);
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
}, []);
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
@@ -177,8 +165,6 @@ function QuerySearch({
setIsEditorReady(true);
}, []);
const prevQueryDataExpressionRef = useRef<string | undefined>();
useEffect(
() => {
if (!isEditorReady) {
@@ -187,22 +173,13 @@ function QuerySearch({
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const prevExpression = prevQueryDataExpressionRef.current;
// Only sync editor when queryData.filter?.expression actually changed from external source
// Not when focus changed (which would reset uncommitted user input)
const queryDataExpressionChanged = prevExpression !== newExpression;
prevQueryDataExpressionRef.current = newExpression;
if (
queryDataExpressionChanged &&
newExpression !== currentExpression &&
!isFocused
) {
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
}
if (!isFocused) {
validateExpressionForEditor(currentExpression);
if (newExpression) {
handleQueryValidation(newExpression);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -309,7 +286,7 @@ function QuerySearch({
}
});
}
setKeySuggestions([...merged.values()]);
setKeySuggestions(Array.from(merged.values()));
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
@@ -362,7 +339,7 @@ function QuerySearch({
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replaceAll(/'/g, "\\'");
const escapedValue = value.replace(/'/g, "\\'");
return `'${escapedValue}'`;
}
@@ -639,7 +616,7 @@ function QuerySearch({
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
validateExpressionForEditor(currentExpression);
handleQueryValidation(currentExpression);
setIsFocused(false);
};
@@ -657,6 +634,7 @@ function QuerySearch({
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
@@ -921,12 +899,12 @@ function QuerySearch({
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.has(option.label) ? -10 : 10,
boost: usedKeys.includes(option.label) ? -10 : 10,
}));
}
@@ -1341,19 +1319,6 @@ function QuerySearch({
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
@@ -1393,7 +1358,6 @@ function QuerySearch({
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
@@ -1428,12 +1392,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
onRun(getCurrentExpression());
}
return true;
},
@@ -1598,7 +1557,6 @@ QuerySearch.defaultProps = {
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};
export default QuerySearch;

View File

@@ -1,58 +0,0 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -1,40 +0,0 @@
export function combineInitialAndUserExpression(
initial: string,
user: string,
): string {
const i = initial.trim();
const u = user.trim();
if (!i) {
return u;
}
if (!u) {
return i;
}
return `${i} AND (${u})`;
}
export function getUserExpressionFromCombined(
initial: string,
combined: string | null | undefined,
): string {
const i = initial.trim();
const c = (combined ?? '').trim();
if (!c) {
return '';
}
if (!i) {
return c;
}
if (c === i) {
return '';
}
const wrappedPrefix = `${i} AND (`;
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
return c.slice(wrappedPrefix.length, -1);
}
const plainPrefix = `${i} AND `;
if (c.startsWith(plainPrefix)) {
return c.slice(plainPrefix.length);
}
return c;
}

View File

@@ -1,14 +0,0 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {
QueryBuilderV2Provider,
useQueryBuilderV2Context,
} from './QueryBuilderV2Context';

View File

@@ -37,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -46,6 +47,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
],
);

View File

@@ -25,6 +25,8 @@ export default function ChartWrapper({
showTooltip = true,
showLegend = true,
canPinTooltip = false,
pinKey,
onClick,
syncMode,
syncKey,
onDestroy = noop,
@@ -101,6 +103,8 @@ export default function ChartWrapper({
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onClick}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,

View File

@@ -26,10 +26,11 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
...props,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
);
return (

View File

@@ -21,10 +21,17 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[
customTooltip,
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
],
);
return (

View File

@@ -13,6 +13,12 @@ interface BaseChartProps {
showTooltip?: boolean;
showLegend?: boolean;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;

View File

@@ -121,6 +121,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
data={chartData as uPlot.AlignedData}

View File

@@ -89,6 +89,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
canPinTooltip
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}

View File

@@ -112,6 +112,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}

View File

@@ -1,73 +1,22 @@
.uplot-tooltip-container {
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--bg-ink-100);
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
.uplot-tooltip-divider {
background-color: var(--bg-vanilla-300);
}
&.pinned {
border-color: var(--ring);
}
.uplot-tooltip-header-container {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: 8px;
&:last-child {
padding-bottom: 1rem;
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
}
.uplot-tooltip-divider {
.divider {
width: 100%;
height: 1px;
background-color: var(--bg-ink-100);
}
.uplot-tooltip-list {
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
// horizontal offset when the scroller has padding or transform applied.
div[data-viewport-type='element'] {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
background-color: var(--l2-border);
}
}

View File

@@ -1,71 +1,28 @@
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useMemo } from 'react';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
import TooltipItem from './components/TooltipItem/TooltipItem';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import Styles from './Tooltip.module.scss';
// Fallback per-item height used for the initial size estimate before
// Virtuoso reports the real total height via totalListHeightChanged.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
export default function Tooltip({
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
canPinTooltip,
dismiss,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone: userTimezone } = useTimezone();
const [totalListHeight, setTotalListHeight] = useState(0);
const tooltipContent = useMemo(() => content ?? [], [content]);
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
const activeItem = useMemo(
() => tooltipContent.find((item) => item.isActive) ?? null,
[tooltipContent],
);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on the first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const virtuosoHeight = useMemo(() => {
return totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
}, [totalListHeight, tooltipContent.length]);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
@@ -74,46 +31,24 @@ export default function Tooltip({
return (
<div
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
className={cx(Styles.container, isPinned && Styles.pinned)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
<div className={Styles.uplotTooltipHeaderContainer}>
{showTooltipHeader && headerTitle && (
<div
className={Styles.uplotTooltipHeader}
data-testid="uplot-tooltip-header"
>
<span>{headerTitle}</span>
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
)}
{showDivider && <span className={Styles.uplotTooltipDivider} />}
{showList && (
<Virtuoso
className={Styles.uplotTooltipList}
data-testid="uplot-tooltip-list"
data={tooltipContent}
style={{ height: virtuosoHeight, width: '100%' }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
<TooltipHeader
uPlotInstance={uPlotInstance}
timezone={timezone}
showTooltipHeader={showTooltipHeader}
isPinned={isPinned}
activeItem={activeItem}
/>
)}
{showDivider && <span className={Styles.divider} />}
{showList && <TooltipList content={tooltipContent} />}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import userEvent from '@testing-library/user-event';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -92,7 +93,6 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(
@@ -191,3 +191,85 @@ describe('Tooltip', () => {
expect(list).toHaveStyle({ height: '76px' });
});
});
describe('Tooltip footer hint', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
});
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
});
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
});
it('calls dismiss when Unpin button is clicked', async () => {
const dismiss = jest.fn();
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const user = userEvent.setup();
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('shows Pinned status when pinned and header is visible', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, isPinned: true });
expect(screen.getByText('Pinned')).toBeInTheDocument();
});
it('does not render status pill when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, showTooltipHeader: false, isPinned: false });
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 12px;
border-top: 1px solid var(--l2-border);
background: var(--l2-background);
border-radius: 0 0 6px 6px;
}
.hint {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,58 @@
import { Button } from '@signozhq/ui';
import { Kbd } from '@signozhq/ui';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { X } from 'lucide-react';
import Styles from './TooltipFooter.module.scss';
interface TooltipFooterProps {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
dismiss,
}: TooltipFooterProps): JSX.Element {
return (
<div
className={Styles.footer}
role="status"
data-testid="uplot-tooltip-footer"
>
<div className={Styles.hint}>
{isPinned ? (
<>
<span>Press</span>
<Kbd active>{pinKey.toUpperCase()}</Kbd>
<span>or</span>
<Kbd active>Esc</Kbd>
<span>to unpin</span>
</>
) : (
<>
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>
<span>to pin the tooltip</span>
</>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={dismiss}
aria-label="Unpin tooltip"
data-testid="uplot-tooltip-unpin"
>
<X size={10} />
<span>Unpin</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
.headerContainer {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
&:last-child {
padding-bottom: var(--spacing-4);
}
}
.headerRow {
font-size: var(--font-size-sm);
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipHeader.module.scss';
interface TooltipHeaderProps {
uPlotInstance: uPlot;
timezone?: Timezone;
showTooltipHeader: boolean;
isPinned: boolean;
activeItem: TooltipContentItem | null;
}
export default function TooltipHeader({
uPlotInstance,
timezone,
showTooltipHeader,
isPinned,
activeItem,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
return (
<div
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && headerTitle && (
<div className={Styles.headerRow}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
.listLightMode {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}

View File

@@ -0,0 +1,48 @@
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
content: TooltipContentItem[];
}
export default function TooltipList({
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [totalListHeight, setTotalListHeight] = useState(0);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
return (
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
);
}

View File

@@ -62,6 +62,7 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

@@ -1,4 +1,4 @@
.tooltip-plugin-container {
.tooltipPluginContainer {
top: 0;
left: 0;
width: 100%;
@@ -10,13 +10,9 @@
transform: translate(-1000px, -1000px); // hide the tooltip initially
opacity: 0;
pointer-events: none;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&.visible {
opacity: 1;
pointer-events: all;
}
}
.visible {
opacity: 1;
pointer-events: all;
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
@@ -17,18 +16,21 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
TooltipClickData,
DEFAULT_PIN_TOOLTIP_KEY,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import { createInitialViewState, createLayoutObserver } from './utils';
import {
buildClickData,
createInitialViewState,
createLayoutObserver,
} from './utils';
import './TooltipPlugin.styles.scss';
import Styles from './TooltipPlugin.module.scss';
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
const HOVER_DISMISS_DELAY_MS = 100;
@@ -44,6 +46,8 @@ export default function TooltipPlugin({
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
onClick,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const rafId = useRef<number | null>(null);
@@ -131,8 +135,8 @@ export default function TooltipPlugin({
// Dismiss the tooltip when the user clicks / presses a key
// outside the tooltip container while it is pinned.
const onOutsideInteraction = (event: Event): void => {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
const target = event.target as Node;
if (!containerRef.current?.contains(target)) {
dismissTooltip();
}
};
@@ -159,13 +163,14 @@ export default function TooltipPlugin({
// Attach / detach global listeners when pin state changes so
// we can detect when the user interacts outside the tooltip.
// Keyboard unpinning is handled exclusively in handleKeyDown so
// that only P (toggle) and Escape (release) can dismiss — not
// arbitrary keystrokes like arrow keys or Tab.
function toggleOutsideListeners(enable: boolean): void {
if (enable) {
document.addEventListener('mousedown', onOutsideInteraction, true);
document.addEventListener('keydown', onOutsideInteraction, true);
} else {
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
}
}
@@ -283,66 +288,84 @@ export default function TooltipPlugin({
}
};
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
// Uses getPlot(controller) to avoid closing over u (plot), which
// would retain the plot and detached canvases across unmounts.
const handleUPlotOverClick = (event: MouseEvent): void => {
// Handles all tooltip-pin keyboard interactions:
// Escape — always releases the tooltip when pinned (never steals Escape
// from other handlers since we do not call stopPropagation).
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
const handleKeyDown = (event: KeyboardEvent): void => {
// Escape: release-only (never toggles on).
if (event.key === 'Escape') {
if (controller.pinned) {
dismissTooltip();
}
return;
}
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
return;
}
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
dismissTooltip();
return;
}
// Toggle on: P pressed while hovering.
const plot = getPlot(controller);
if (
!plot ||
!controller.hoverActive ||
controller.focusedSeriesIndex == null
) {
return;
}
const cursorLeft = plot.cursor.left ?? -1;
const cursorTop = plot.cursor.top ?? -1;
if (cursorLeft < 0 || cursorTop < 0) {
return;
}
const plotRect = plot.over.getBoundingClientRect();
const syntheticEvent = ({
clientX: plotRect.left + cursorLeft,
clientY: plotRect.top + cursorTop,
target: plot.over,
offsetX: cursorLeft,
offsetY: cursorTop,
} as unknown) as MouseEvent;
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
scheduleRender(true);
};
// Forward overlay clicks to the consumer-provided onClick callback.
const handleOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
/**
* Only trigger onClick if the click happened on the plot overlay and there is a focused series.
* It also ensures that clicks only trigger onClick when there is a relevant data point (i.e. a focused series) to provide context for the click.
*/
if (
plot &&
event.target === plot.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
let clickedDataTimestamp = xValue;
if (focusedSeries) {
const dataIndex = plot.posToIdx(event.offsetX);
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
}
const clickData: TooltipClickData = {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
controller.clickData = clickData;
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
}, 0);
const clickData = buildClickData(event, plot);
onClick?.(clickData);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
// Called once per uPlot instance; used to store the instance on the controller.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ hasPlot: true });
if (canPinTooltip) {
overClickHandler = handleUPlotOverClick;
if (onClick) {
overClickHandler = handleOverClick;
u.over.addEventListener('click', overClickHandler);
}
};
@@ -389,13 +412,18 @@ export default function TooltipPlugin({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleScroll, true);
if (canPinTooltip) {
document.addEventListener('keydown', handleKeyDown, true);
}
return (): void => {
layoutRef.current?.observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
if (canPinTooltip) {
document.removeEventListener('keydown', handleKeyDown, true);
}
cancelPendingRender();
removeReadyHook();
removeInitHook();
@@ -405,9 +433,7 @@ export default function TooltipPlugin({
removeSetCursorHook();
if (overClickHandler) {
const plot = getPlot(controller);
if (plot) {
plot.over.removeEventListener('click', overClickHandler);
}
plot?.over.removeEventListener('click', overClickHandler);
overClickHandler = null;
}
clearPlotReferences();
@@ -447,8 +473,12 @@ export default function TooltipPlugin({
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
if (isPinned) {
if (pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
// No custom pinned element — keep showing the last hover contents.
return contents ?? null;
}
if (isHovering) {
@@ -471,9 +501,8 @@ export default function TooltipPlugin({
return createPortal(
<div
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isTooltipVisible,
className={cx(Styles.tooltipPluginContainer, {
[Styles.visible]: isTooltipVisible,
})}
style={{
...style,
@@ -484,6 +513,7 @@ export default function TooltipPlugin({
aria-atomic="true"
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-pinned={isPinned}
data-testid="tooltip-plugin-container"
>
{tooltipBody}

View File

@@ -102,6 +102,12 @@ export function updateHoverState(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): void {
// When pinned, keep hoverActive stable so the tooltip stays visible
// until explicitly dismissed — the cursor lock fires asynchronously
// and setSeries/setLegend can otherwise race and clear hoverActive.
if (controller.pinned) {
return;
}
// When the cursor is driven by dashboardlevel sync, we only show
// the tooltip if the plot is in viewport and at least one series
// is active. Otherwise we fall back to local interaction logic.

View File

@@ -11,6 +11,9 @@ import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
// Default key that pins the tooltip while hovering over the chart.
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair,
None,
@@ -41,6 +44,10 @@ export interface TooltipSyncMetadata {
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;

View File

@@ -1,4 +1,11 @@
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
@@ -158,3 +165,40 @@ export function createLayoutObserver(
};
return layout;
}
/**
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
* and the current uPlot instance. Shared by the overlay click handler and the
* keyboard-pin handler (which synthesises an event from the cursor position).
*/
export function buildClickData(
event: MouseEvent,
plot: uPlot,
): TooltipClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
let clickedDataTimestamp = xValue;
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
return {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
}

View File

@@ -7,7 +7,10 @@ import type uPlot from 'uplot';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import { DashboardCursorSync } from '../TooltipPlugin/types';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
} from '../TooltipPlugin/types';
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
// We only care that pinning logic runs without throwing, not which series is focused.
@@ -60,7 +63,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown> };
cursor: { event: Record<string, unknown>; left: number; top: number };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
@@ -71,7 +74,9 @@ function createFakePlot(): {
return {
over,
setCursor: jest.fn(),
cursor: { event: {} },
// left / top are set to valid values so keyboard-pin tests do not
// hit the "cursor off-screen" guard inside handleKeyDown.
cursor: { event: {}, left: 50, top: 50 },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
@@ -144,7 +149,7 @@ describe('TooltipPlugin', () => {
}),
);
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
});
it('registers all required uPlot hooks on mount', () => {
@@ -182,9 +187,7 @@ describe('TooltipPlugin', () => {
expect(renderTooltip).toHaveBeenCalled();
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
@@ -203,9 +206,7 @@ describe('TooltipPlugin', () => {
renderAndActivateHover(config);
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.parentElement).toBe(document.body);
const fullscreenRoot = document.createElement('div');
@@ -245,24 +246,27 @@ describe('TooltipPlugin', () => {
// ---- Pin behaviour ----------------------------------------------------------
describe('pin behaviour', () => {
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
const config = createConfigMock();
const fakePlot = renderAndActivateHover(config, undefined, {
canPinTooltip: true,
});
renderAndActivateHover(config, undefined, { canPinTooltip: true });
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
return waitFor(() => {
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.classList.contains('pinned')).toBe(true);
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
});
});
@@ -272,7 +276,7 @@ describe('TooltipPlugin', () => {
React.createElement('div', null, 'pinned-tooltip'),
);
const fakePlot = renderAndActivateHover(
renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
@@ -284,7 +288,12 @@ describe('TooltipPlugin', () => {
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
await waitFor(() => {
@@ -318,18 +327,20 @@ describe('TooltipPlugin', () => {
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
// Pin the tooltip.
// Pin the tooltip via the keyboard shortcut.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
// Wait until the tooltip is actually pinned (pointer events enabled)
// Wait until the tooltip is actually pinned.
await waitFor(() => {
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(container).not.toBeNull();
expect(container?.classList.contains('pinned')).toBe(true);
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
});
const button = await screen.findByRole('button', { name: 'Dismiss' });
@@ -342,8 +353,7 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -369,16 +379,21 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin.
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
(document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement)?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Simulate data update should dismiss the pinned tooltip.
@@ -390,8 +405,7 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
jest.useRealTimers();
});
@@ -417,15 +431,21 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Click outside the tooltip container.
@@ -439,14 +459,13 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip on outside keydown', async () => {
it('unpins the tooltip when Escape is pressed while pinned', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -467,18 +486,24 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
// Press a key outside the tooltip.
// Press Escape to release.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
@@ -490,12 +515,282 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip when the pin key is pressed a second time (toggle off)', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// First press — pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Second press — unpin (toggle off).
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('does not unpin on Escape when tooltip is not pinned', () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
// Escape without pinning first — should be a no-op.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
// Tooltip should still be hovering (visible), not dismissed.
expect(container.getAttribute('aria-hidden')).toBe('false');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not unpin on arbitrary keys that are not Escape or the pin key', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// Pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Arrow key — should NOT unpin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
jest.useRealTimers();
});
});
// ---- Keyboard pin edge cases ------------------------------------------------
describe('keyboard pin edge cases', () => {
it('does not pin when cursor coordinates are negative (cursor off-screen)', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
// Negative cursor coords — handleKeyDown bails out before pinning.
const fakePlot = {
...createFakePlot(),
cursor: { event: {}, left: -1, top: -1 },
};
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not pin when hover is not active', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
// Initialise the plot but do NOT call setSeries hoverActive stays false.
getHandler(config, 'init')(fakePlot);
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
// The container exists once the plot is initialised, but it should
// be hidden and not pinned since hover was never activated.
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.getAttribute('aria-hidden')).toBe('true');
});
it('ignores other keys and only pins on the configured pinKey', async () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, {
canPinTooltip: true,
pinKey: 'p',
});
// 'l' should NOT pin when pinKey is 'p'.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'l',
bubbles: true,
}),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(false);
});
// Custom pin key 'p' SHOULD pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'p', bubbles: true }),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
});
it('does not register a keydown listener when canPinTooltip is false', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: false,
}),
);
const keydownCalls = addSpy.mock.calls.filter(
([type]) => type === 'keydown',
);
expect(keydownCalls).toHaveLength(0);
});
it('removes the keydown pin listener on unmount', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
const removeSpy = jest.spyOn(document, 'removeEventListener');
const { unmount } = render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const pinListenerCall = addSpy.mock.calls.find(
([type]) => type === 'keydown',
);
expect(pinListenerCall).toBeDefined();
if (!pinListenerCall) {
return;
}
const [, pinListener, pinOptions] = pinListenerCall;
unmount();
expect(removeSpy).toHaveBeenCalledWith('keydown', pinListener, pinOptions);
});
});
// ---- Cursor sync ------------------------------------------------------------

View File

@@ -5593,10 +5593,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.9.tgz#e00f2ec86c5528eea91d1669510a702c4253de0d"
integrity sha512-L9DV0OF69Z2sMnxwPEGpSTiDxI/liT6+QfzngGVnxMl/9t3WKcPr1/p8dbPOcAS6bU9lQEBOiYlsoIejM8DsXw==
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"