Compare commits

..

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
2e04d805e9 test(useResourceAttribute): add ResourceProvider behavior coverage
Covers initial state, URL hydration, step-machine transitions (Idle ->
TagKey -> Operator -> TagValue), handleBlur commit/purge paths,
handleClose/handleClearAll, handleEnvironmentChange (add, clear,
replace, dot-metrics feature flag, preserving unrelated URL params),
and SERVICE_MAP visibility filtering.

Tests exercise only the public IResourceAttributeProps contract so they
serve as a behavior pin for any future refactor of the internal state
machine.
2026-04-23 13:07:25 +05:30
35 changed files with 874 additions and 1133 deletions

View File

@@ -92,7 +92,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, registry ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, registry...), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)

View File

@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -38,7 +37,6 @@ 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.10",
"@signozhq/ui": "0.0.9",
"@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

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

View File

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

View File

@@ -26,11 +26,10 @@ 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, rest.canPinTooltip],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -21,17 +21,10 @@ 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,
rest.canPinTooltip,
],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

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

View File

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

View File

@@ -0,0 +1,577 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Router } from 'react-router-dom';
import { act, renderHook, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { createMemoryHistory, MemoryHistory } from 'history';
import { encode } from 'js-base64';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import { getAppContextMock } from 'tests/test-utils';
import ResourceProvider from '../ResourceProvider';
import useResourceAttribute from '../useResourceAttribute';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
pathname: '/',
},
},
}));
jest.mock('api/metrics/getResourceAttributes', () => ({
getResourceAttributesTagKeys: jest.fn(),
getResourceAttributesTagValues: jest.fn(),
}));
// eslint-disable-next-line import/first, import/order
import {
getResourceAttributesTagKeys,
getResourceAttributesTagValues,
// eslint-disable-next-line import/newline-after-import
} from 'api/metrics/getResourceAttributes';
// eslint-disable-next-line import/first, import/order
import history from 'lib/history';
const mockTagKeys = getResourceAttributesTagKeys as jest.MockedFunction<
typeof getResourceAttributesTagKeys
>;
const mockTagValues = getResourceAttributesTagValues as jest.MockedFunction<
typeof getResourceAttributesTagValues
>;
function createWrapper({
routerHistory,
appContextOverrides,
}: {
routerHistory: MemoryHistory;
appContextOverrides?: Partial<IAppContext>;
}): ({ children }: { children: ReactNode }) => JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock('ADMIN', appContextOverrides)}>
<Router history={routerHistory}>
<ResourceProvider>{children}</ResourceProvider>
</Router>
</AppContext.Provider>
</QueryClientProvider>
);
};
}
function mockLibHistory(search = '', pathname = '/'): void {
(history.location as { search: string; pathname: string }).search = search;
(history.location as { search: string; pathname: string }).pathname = pathname;
}
type TagKeysPayload = Parameters<typeof mockTagKeys.mockResolvedValue>[0];
type TagValuesPayload = Parameters<typeof mockTagValues.mockResolvedValue>[0];
function successTagKeysPayload(keys: string[]): TagKeysPayload {
return ({
statusCode: 200,
error: null,
message: 'ok',
payload: {
data: {
attributeKeys: keys.map((key) => ({
key,
dataType: 'string',
type: 'resource',
isColumn: false,
})),
},
},
} as unknown) as TagKeysPayload;
}
function successTagValuesPayload(values: string[]): TagValuesPayload {
return ({
statusCode: 200,
error: null,
message: 'ok',
payload: {
data: {
stringAttributeValues: values,
},
},
} as unknown) as TagValuesPayload;
}
describe('ResourceProvider', () => {
beforeEach(() => {
mockSafeNavigate.mockReset();
mockTagKeys.mockReset();
mockTagValues.mockReset();
mockLibHistory('', '/');
});
describe('initial state', () => {
it('starts loading with empty staging, selectedQuery, and queries', () => {
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
expect(result.current.loading).toBe(true);
expect(result.current.queries).toStrictEqual([]);
expect(result.current.staging).toStrictEqual([]);
expect(result.current.selectedQuery).toStrictEqual([]);
expect(result.current.optionsData).toStrictEqual({ mode: undefined, options: [] });
});
it('hydrates queries from the resourceAttribute URL param on mount', () => {
const seeded = [
{
id: 'abc',
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
},
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
expect(result.current.queries).toStrictEqual(seeded);
});
});
describe('state-machine transitions via handleFocus / handleChange', () => {
it('Idle → TagKey fetches tag keys and populates options', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => {
expect(mockTagKeys).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
expect(result.current.optionsData.options).toStrictEqual([
{ label: 'service.name', value: 'resource_service_name' },
]);
});
});
it('TagKey → Operator sets OperatorSchema on handleChange (no mode)', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.handleChange('resource_service_name');
});
expect(result.current.staging).toStrictEqual(['resource_service_name']);
expect(result.current.optionsData.options).toStrictEqual([
{ label: 'IN', value: 'IN' },
{ label: 'Not IN', value: 'Not IN' },
]);
});
it('Operator → TagValue fetches values using staging[0]', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend', 'backend']));
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.handleChange('resource_service_name');
});
act(() => {
result.current.handleChange('IN');
});
await waitFor(() => {
expect(mockTagValues).toHaveBeenCalledWith(
expect.objectContaining({ tagKey: 'resource_service_name' }),
);
expect(result.current.optionsData.mode).toBe('multiple');
expect(result.current.optionsData.options).toStrictEqual([
{ label: 'frontend', value: 'frontend' },
{ label: 'backend', value: 'backend' },
]);
});
});
it('handleChange with mode updates selectedQuery instead of staging', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend']));
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.handleChange('resource_service_name');
});
act(() => {
result.current.handleChange('IN');
});
await waitFor(() => expect(result.current.optionsData.mode).toBe('multiple'));
act(() => {
// In multiple mode, handleChange treats value as iterable of selected values.
result.current.handleChange(('frontend' as unknown) as string);
});
expect(result.current.selectedQuery).toStrictEqual([
'f',
'r',
'o',
'n',
't',
'e',
'n',
'd',
]);
// Staging not advanced by mode-mode handleChange
expect(result.current.staging).toStrictEqual(['resource_service_name', 'IN']);
});
});
describe('handleBlur', () => {
it('commits a query when TagValue staging is complete and selectedQuery non-empty', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend']));
const routerHistory = createMemoryHistory({ initialEntries: ['/svc'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.handleChange('resource_service_name'));
act(() => result.current.handleChange('IN'));
await waitFor(() => expect(result.current.optionsData.mode).toBe('multiple'));
act(() => {
// Build selectedQuery = ['frontend']
result.current.handleChange((['frontend'] as unknown) as string);
});
act(() => {
result.current.handleBlur();
});
await waitFor(() => {
expect(result.current.queries).toHaveLength(1);
expect(result.current.queries[0]).toMatchObject({
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
});
expect(result.current.staging).toStrictEqual([]);
expect(result.current.selectedQuery).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
it('resets state without committing when staging is incomplete', async () => {
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleFocus();
});
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.handleChange('resource_service_name'));
act(() => {
result.current.handleBlur();
});
expect(result.current.queries).toStrictEqual([]);
expect(result.current.staging).toStrictEqual([]);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
});
describe('handleClose / handleClearAll', () => {
it('handleClose removes the matching query and navigates', async () => {
const seeded = [
{ id: 'a', tagKey: 'resource_a', operator: 'IN', tagValue: ['x'] },
{ id: 'b', tagKey: 'resource_b', operator: 'IN', tagValue: ['y'] },
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
expect(result.current.queries).toHaveLength(2);
act(() => {
result.current.handleClose('a');
});
await waitFor(() => {
expect(result.current.queries).toStrictEqual([seeded[1]]);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
it('handleClearAll wipes queries, staging, selectedQuery, options', async () => {
const seeded = [
{ id: 'a', tagKey: 'resource_a', operator: 'IN', tagValue: ['x'] },
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleClearAll();
});
await waitFor(() => {
expect(result.current.queries).toStrictEqual([]);
expect(result.current.staging).toStrictEqual([]);
expect(result.current.optionsData).toStrictEqual({
mode: undefined,
options: [],
});
});
});
});
describe('handleEnvironmentChange', () => {
it('adds an environment query when envs are provided', async () => {
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleEnvironmentChange(['production']);
});
await waitFor(() => {
expect(result.current.queries).toHaveLength(1);
expect(result.current.queries[0]).toMatchObject({
tagKey: 'resource_deployment_environment',
operator: 'IN',
tagValue: ['production'],
});
});
});
it('clears the environment query when an empty array is passed', async () => {
const seeded = [
{
id: 'env',
tagKey: 'resource_deployment_environment',
operator: 'IN',
tagValue: ['production'],
},
{
id: 'svc',
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
},
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleEnvironmentChange([]);
});
await waitFor(() => {
const tagKeys = result.current.queries.map((q) => q.tagKey);
expect(tagKeys).not.toContain('resource_deployment_environment');
expect(tagKeys).toContain('resource_service_name');
});
});
it('replaces an existing environment query rather than appending', async () => {
const seeded = [
{
id: 'env',
tagKey: 'resource_deployment_environment',
operator: 'IN',
tagValue: ['production'],
},
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleEnvironmentChange(['staging']);
});
await waitFor(() => {
const envQueries = result.current.queries.filter(
(q) => q.tagKey === 'resource_deployment_environment',
);
expect(envQueries).toHaveLength(1);
expect(envQueries[0].tagValue).toStrictEqual(['staging']);
});
});
it('uses the dotted deployment env key when DOT_METRICS_ENABLED is active', async () => {
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({
routerHistory,
appContextOverrides: {
featureFlags: [
{
name: FeatureKeys.DOT_METRICS_ENABLED,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
}),
});
act(() => {
result.current.handleEnvironmentChange(['production']);
});
await waitFor(() => {
expect(result.current.queries[0].tagKey).toBe(
'resource_deployment.environment',
);
});
});
it('preserves unrelated query params when dispatching', async () => {
const routerHistory = createMemoryHistory({
initialEntries: ['/?tab=overview'],
});
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
act(() => {
result.current.handleEnvironmentChange(['production']);
});
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
const calledWith = mockSafeNavigate.mock.calls[0][0] as string;
expect(calledWith).toContain('tab=overview');
expect(calledWith).toContain('resourceAttribute=');
});
});
});
describe('getVisibleQueries (SERVICE_MAP filtering)', () => {
it('filters queries down to whitelisted keys on SERVICE_MAP', () => {
const seeded = [
{
id: 'a',
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
},
{
id: 'b',
tagKey: 'resource_k8s_cluster_name',
operator: 'IN',
tagValue: ['prod'],
},
];
mockLibHistory(
`?resourceAttribute=${encode(JSON.stringify(seeded))}`,
ROUTES.SERVICE_MAP,
);
const routerHistory = createMemoryHistory({
initialEntries: [ROUTES.SERVICE_MAP],
});
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
expect(result.current.queries).toStrictEqual([seeded[1]]);
});
it('returns all queries on non-SERVICE_MAP routes', () => {
const seeded = [
{
id: 'a',
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
},
{
id: 'b',
tagKey: 'resource_k8s_cluster_name',
operator: 'IN',
tagValue: ['prod'],
},
];
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/services');
const routerHistory = createMemoryHistory({ initialEntries: ['/services'] });
const { result } = renderHook(() => useResourceAttribute(), {
wrapper: createWrapper({ routerHistory }),
});
expect(result.current.queries).toHaveLength(2);
});
});
});

View File

@@ -1,22 +1,73 @@
.container {
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
color: var(--bg-vanilla-100);
border-radius: 6px;
border: 1px solid var(--l2-border);
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
&.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);
}
}
.divider {
.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 {
width: 100%;
height: 1px;
background-color: var(--l2-border);
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;
}
}
}

View File

@@ -1,28 +1,71 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
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 TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import TooltipItem from './components/TooltipItem/TooltipItem';
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.
@@ -31,24 +74,46 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, isPinned && Styles.pinned)}
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
<TooltipHeader
uPlotInstance={uPlotInstance}
timezone={timezone}
showTooltipHeader={showTooltipHeader}
isPinned={isPinned}
activeItem={activeItem}
/>
<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.divider} />}
{showDivider && <span className={Styles.uplotTooltipDivider} />}
{showList && <TooltipList content={tooltipContent} />}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
{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} />
)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,5 @@
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';
@@ -93,6 +92,7 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(
@@ -191,85 +191,3 @@ 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

@@ -1,18 +0,0 @@
.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

@@ -1,58 +0,0 @@
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

@@ -1,32 +0,0 @@
.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

@@ -1,85 +0,0 @@
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

@@ -1,27 +0,0 @@
.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

@@ -1,48 +0,0 @@
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,7 +62,6 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

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

View File

@@ -1,6 +1,7 @@
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';
@@ -16,21 +17,18 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
TooltipClickData,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import {
buildClickData,
createInitialViewState,
createLayoutObserver,
} from './utils';
import { createInitialViewState, createLayoutObserver } from './utils';
import Styles from './TooltipPlugin.module.scss';
import './TooltipPlugin.styles.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;
@@ -46,8 +44,6 @@ 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);
@@ -135,8 +131,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 Node;
if (!containerRef.current?.contains(target)) {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
dismissTooltip();
}
};
@@ -163,14 +159,13 @@ 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);
}
}
@@ -288,84 +283,66 @@ export default function TooltipPlugin({
}
};
// 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.
// 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 => {
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 clickData = buildClickData(event, plot);
onClick?.(clickData);
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);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance on the controller.
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ hasPlot: true });
if (onClick) {
overClickHandler = handleOverClick;
if (canPinTooltip) {
overClickHandler = handleUPlotOverClick;
u.over.addEventListener('click', overClickHandler);
}
};
@@ -412,18 +389,13 @@ 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);
if (canPinTooltip) {
document.removeEventListener('keydown', handleKeyDown, true);
}
document.removeEventListener('keydown', onOutsideInteraction, true);
cancelPendingRender();
removeReadyHook();
removeInitHook();
@@ -433,7 +405,9 @@ export default function TooltipPlugin({
removeSetCursorHook();
if (overClickHandler) {
const plot = getPlot(controller);
plot?.over.removeEventListener('click', overClickHandler);
if (plot) {
plot.over.removeEventListener('click', overClickHandler);
}
overClickHandler = null;
}
clearPlotReferences();
@@ -473,12 +447,8 @@ export default function TooltipPlugin({
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
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 (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
if (isHovering) {
@@ -501,8 +471,9 @@ export default function TooltipPlugin({
return createPortal(
<div
className={cx(Styles.tooltipPluginContainer, {
[Styles.visible]: isTooltipVisible,
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isTooltipVisible,
})}
style={{
...style,
@@ -513,7 +484,6 @@ export default function TooltipPlugin({
aria-atomic="true"
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-pinned={isPinned}
data-testid="tooltip-plugin-container"
>
{tooltipBody}

View File

@@ -102,12 +102,6 @@ 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,9 +11,6 @@ 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,
@@ -44,10 +41,6 @@ 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,11 +1,4 @@
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
@@ -165,40 +158,3 @@ 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,10 +7,7 @@ import type uPlot from 'uplot';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
} from '../TooltipPlugin/types';
import { DashboardCursorSync } 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.
@@ -63,7 +60,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown>; left: number; top: number };
cursor: { event: Record<string, unknown> };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
@@ -74,9 +71,7 @@ function createFakePlot(): {
return {
over,
setCursor: jest.fn(),
// 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 },
cursor: { event: {} },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
@@ -149,7 +144,7 @@ describe('TooltipPlugin', () => {
}),
);
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
});
it('registers all required uPlot hooks on mount', () => {
@@ -187,7 +182,9 @@ describe('TooltipPlugin', () => {
expect(renderTooltip).toHaveBeenCalled();
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
const container = screen.getByTestId('tooltip-plugin-container');
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
@@ -206,7 +203,9 @@ describe('TooltipPlugin', () => {
renderAndActivateHover(config);
const container = screen.getByTestId('tooltip-plugin-container');
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container.parentElement).toBe(document.body);
const fullscreenRoot = document.createElement('div');
@@ -246,27 +245,24 @@ describe('TooltipPlugin', () => {
// ---- Pin behaviour ----------------------------------------------------------
describe('pin behaviour', () => {
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
const fakePlot = renderAndActivateHover(config, undefined, {
canPinTooltip: true,
});
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
return waitFor(() => {
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
expect(updated.classList.contains('pinned')).toBe(true);
});
});
@@ -276,7 +272,7 @@ describe('TooltipPlugin', () => {
React.createElement('div', null, 'pinned-tooltip'),
);
renderAndActivateHover(
const fakePlot = renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
@@ -288,12 +284,7 @@ describe('TooltipPlugin', () => {
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await waitFor(() => {
@@ -327,20 +318,18 @@ describe('TooltipPlugin', () => {
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
// Pin the tooltip via the keyboard shortcut.
// Pin the tooltip.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Wait until the tooltip is actually pinned.
// Wait until the tooltip is actually pinned (pointer events enabled)
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(container).not.toBeNull();
expect(container?.classList.contains('pinned')).toBe(true);
});
const button = await screen.findByRole('button', { name: 'Dismiss' });
@@ -353,7 +342,8 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -379,21 +369,16 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
// Pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
(document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement)?.classList.contains('pinned'),
).toBe(true);
// Simulate data update should dismiss the pinned tooltip.
@@ -405,7 +390,8 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
jest.useRealTimers();
});
@@ -431,21 +417,15 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Click outside the tooltip container.
@@ -459,13 +439,14 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip when Escape is pressed while pinned', async () => {
it('unpins the tooltip on outside keydown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -486,24 +467,18 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Press Escape to release.
// Press a key outside the tooltip.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
@@ -515,282 +490,12 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).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.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
"@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==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"

View File

@@ -10,34 +10,8 @@ import (
"github.com/gorilla/mux"
)
var (
serviceAccountAdminRoles = []string{
authtypes.SigNozAdminRoleName,
}
serviceAccountReadRoles = []string{
authtypes.SigNozAdminRoleName,
authtypes.SigNozEditorRoleName,
authtypes.SigNozViewerRoleName,
}
)
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResources, authtypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
id := mux.Vars(req)["id"]
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, nil
}
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
// POST /api/v1/service_accounts — Create (admin only)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Create, authtypes.RelationCreate, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
@@ -54,8 +28,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts — List (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.List, authtypes.RelationList, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
@@ -67,12 +40,11 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// GET /api/v1/service_accounts/me — GetMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
ID: "GetMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -90,8 +62,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts/{id} — Get (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Get, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
@@ -103,13 +74,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// GET /api/v1/service_accounts/{id}/roles — GetRoles (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.GetRoles, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
@@ -121,13 +91,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// POST /api/v1/service_accounts/{id}/roles — SetRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.SetRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
@@ -144,8 +113,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id}/roles/{rid} — DeleteRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.DeleteRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
@@ -162,7 +130,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// PUT /api/v1/service_accounts/me — UpdateMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
ID: "UpdateMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -180,8 +147,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// PUT /api/v1/service_accounts/{id} — Update (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Update, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
@@ -198,8 +164,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id} — Delete (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Delete, authtypes.RelationDelete, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
@@ -216,8 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// POST /api/v1/service_accounts/{id}/keys — CreateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
@@ -234,8 +198,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts/{id}/keys — ListFactorAPIKey (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
@@ -247,13 +210,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// PUT /api/v1/service_accounts/{id}/keys/{fid} — UpdateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
@@ -270,8 +232,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id}/keys/{fid} — RevokeFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",

View File

@@ -376,21 +376,6 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
// Verify the caller holds the role they are trying to assign. This prevents
// privilege escalation — a caller cannot grant a role they don't have.
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
}
roleSelector := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.Name),
}
err = module.authz.CheckWithTupleCreation(ctx, claims, orgID, authtypes.RelationAssignee, authtypes.TypeableRole, roleSelector, roleSelector)
if err != nil {
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "caller does not hold the role %q being assigned", role.Name)
}
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
if err != nil {
return err
@@ -445,4 +430,3 @@ func apiKeyCacheKey(apiKey string) string {
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -1,135 +0,0 @@
package implserviceaccount
import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// typeableRegistry is a standalone RegisterTypeable implementation that can be
// used independently of the module lifecycle. This is needed because authz
// initialization requires RegisterTypeable entries before the service account
// module is created (the module depends on authz).
type typeableRegistry struct{}
func NewTypeableRegistry() authz.RegisterTypeable {
return &typeableRegistry{}
}
func (registry *typeableRegistry) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{
serviceaccounttypes.TypeableMetaResourceServiceAccount,
serviceaccounttypes.TypeableMetaResourcesServiceAccounts,
}
}
func (registry *typeableRegistry) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAdminRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationCreate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationUpdate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationDelete,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozEditorRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozViewerRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}

View File

@@ -100,7 +100,7 @@ func New(
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
@@ -328,11 +328,8 @@ func New(
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize service account typeable registry (decoupled from module lifecycle for DI)
serviceAccountRegistry := implserviceaccount.NewTypeableRegistry()
// Initialize authz
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard, serviceAccountRegistry)
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard)
if err != nil {
return nil, err
}

View File

@@ -26,11 +26,6 @@ var (
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 50 characters long")
)
var (
TypeableMetaResourceServiceAccount = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("service-account"))
TypeableMetaResourcesServiceAccounts = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("service-accounts"))
)
var (
ServiceAccountStatusActive = ServiceAccountStatus{valuer.NewString("active")}
ServiceAccountStatusDeleted = ServiceAccountStatus{valuer.NewString("deleted")}