Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhatkal
a43c15f8ed chore: resolve comments 2026-04-22 21:14:15 +05:30
Ashwin Bhatkal
77f06094bd refactor: clear list of dashboard 2026-04-22 21:03:53 +05:30
Ashwin Bhatkal
47078e2789 refactor(frontend): remove xstate and migrate to plain React state
Replace xstate state machines with useState-based step tracking in the
three remaining consumers (labels form, dashboard search filter,
resource attribute provider). Drops xstate and @xstate/react from
dependencies and removes the corresponding no-restricted-imports
entries from oxlint config.
2026-04-22 20:11:48 +05:30
49 changed files with 426 additions and 2004 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

@@ -298,14 +298,6 @@
"name": "react-redux",
"message": "[State mgmt] react-redux is deprecated. Migrate to Zustand, nuqs, or react-query."
},
{
"name": "xstate",
"message": "[State mgmt] xstate is deprecated. Migrate to Zustand or react-query."
},
{
"name": "@xstate/react",
"message": "[State mgmt] @xstate/react is deprecated. Migrate to Zustand or react-query."
},
{
"name": "react",
"importNames": [

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",
@@ -64,7 +64,6 @@
"@visx/shape": "3.5.0",
"@visx/tooltip": "3.3.0",
"@vitejs/plugin-react": "5.1.4",
"@xstate/react": "^3.0.0",
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
@@ -147,7 +146,6 @@
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-html": "3.2.2",
"web-vitals": "^0.2.4",
"xstate": "^4.31.0",
"zod": "4.3.6",
"zustand": "5.0.11"
},
@@ -266,4 +264,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

@@ -1,50 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createMachine } from 'xstate';
export const ResourceAttributesFilterMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBECGsAWAjA9qgThAAQDKYBAxhkQIIB2xAYgJYA2ALmPgHQAqqUANJgAngGIAcgFEAGr0SgADjljN2zHHQUgAHogAcAFgAM3AOz6ATAEYAzJdsA2Y4cOWAnABoQIxAFpDR2tuQ319AFYTcKdbFycAX3jvNExcAmIySmp6JjZOHn4hUTFNACFWAFd8bWVVdU1tPQQzY1MXY2tDdzNHM3dHd0NvXwR7biMTa313S0i+63DE5PRsPEJScnwqWgYiFg4uPgFhcQAlKRIpeSQQWrUNLRumx3Czbg8TR0sbS31jfUcw38fW47gBHmm4XCVms3SWIBSq3SGyyO1yBx4AHlFFxUOwcPhJLJrkoVPcGk9ENYFuF3i5YR0wtEHECEAEgiEmV8zH1DLYzHZ4Yi0utMltsrt9vluNjcfjCWVKtUbnd6o9QE1rMYBtxbGFvsZ3NrZj1WdYOfotUZLX0XEFHEKViKMpttjk9nlDrL8HiCWJzpcSbcyWrGoh3NCQj0zK53P1ph1WeFLLqnJZ2s5vmZLA6kginWsXaj3VLDoUAGqoSpgEp0cpVGohh5hhDWDy0sz8zruakzamWVm-Qyg362V5-AZOayO1KFlHitEejFHKCV6v+i5XRt1ZuU1s52zjNOOaZfdOWIY+RDZ0Hc6ZmKEXqyLPPCudit2Sz08ACSEFYNbSHI27kuquiIOEjiONwjJgrM3RWJYZisgEIJgnYPTmuEdi2OaiR5nQOAQHA2hvsiH4Sui0qFCcIGhnuLSmP0YJuJ2xjJsmKELG8XZTK0tjdHG06vgW5GupRS7St6vrKqSO4UhqVL8TBWp8o4eqdl0A5Xmy3G6gK56-B4uERDOSKiuJi6lgUAhrhUYB0buimtrEKZBDYrxaS0OZca8+ltheybOI4hivGZzrzp+VGHH+AGOQp4EIHy+ghNYnawtG4TsbYvk8QKfHGAJfQ9uF76WSW37xWBTSGJ0qXpd0vRZdEKGPqC2YeO2-zfO4+HxEAA */
createMachine({
tsTypes: {} as import('./Labels.machine.typegen').Typegen0,
initial: 'Idle',
states: {
LabelKey: {
on: {
NEXT: {
actions: 'onSelectLabelValue',
target: 'LabelValue',
},
onBlur: {
actions: 'onSelectLabelValue',
target: 'LabelValue',
},
RESET: {
target: 'Idle',
},
},
},
LabelValue: {
on: {
NEXT: {
actions: ['onValidateQuery'],
},
onBlur: {
actions: ['onValidateQuery'],
// target: 'Idle',
},
RESET: {
target: 'Idle',
},
},
},
Idle: {
on: {
NEXT: {
actions: 'onSelectLabelKey',
description: 'Enter a label key',
target: 'LabelKey',
},
},
},
},
id: 'Label Key Values',
});

View File

@@ -1,25 +0,0 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true;
eventsCausingActions: {
onSelectLabelValue: 'NEXT' | 'onBlur';
onValidateQuery: 'NEXT' | 'onBlur';
onSelectLabelKey: 'NEXT';
};
internalEvents: {
'xstate.init': { type: 'xstate.init' };
};
invokeSrcNameMap: {};
missingImplementations: {
actions: 'onSelectLabelValue' | 'onValidateQuery' | 'onSelectLabelKey';
services: never;
guards: never;
delays: never;
};
eventsCausingServices: {};
eventsCausingGuards: {};
eventsCausingDelays: {};
matchesStates: 'LabelKey' | 'LabelValue' | 'Idle';
tags: never;
}

View File

@@ -4,20 +4,20 @@ import {
CloseCircleFilled,
ExclamationCircleOutlined,
} from '@ant-design/icons';
// eslint-disable-next-line no-restricted-imports
import { useMachine } from '@xstate/react';
import { Button, Input, message, Modal } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { map } from 'lodash-es';
import { Labels } from 'types/api/alerts/def';
import { v4 as uuid } from 'uuid';
import { ResourceAttributesFilterMachine } from './Labels.machine';
import QueryChip from './QueryChip';
import { QueryChipItem, SearchContainer } from './styles';
import { ILabelRecord } from './types';
import { createQuery, flattenLabels, prepareLabels } from './utils';
type LabelStep = 'Idle' | 'LabelKey' | 'LabelValue';
type LabelEvent = 'NEXT' | 'onBlur' | 'RESET';
interface LabelSelectProps {
onSetLabels: (q: Labels) => void;
initialValues: Labels | undefined;
@@ -35,42 +35,65 @@ function LabelSelect({
const [queries, setQueries] = useState<ILabelRecord[]>(
initialValues ? flattenLabels(initialValues) : [],
);
const [step, setStep] = useState<LabelStep>('Idle');
const dispatchChanges = (updatedRecs: ILabelRecord[]): void => {
onSetLabels(prepareLabels(updatedRecs, initialValues));
setQueries(updatedRecs);
};
const [state, send] = useMachine(ResourceAttributesFilterMachine, {
actions: {
onSelectLabelKey: () => {},
onSelectLabelValue: () => {
if (currentVal !== '') {
setStaging((prevState) => [...prevState, currentVal]);
} else {
return;
}
setCurrentVal('');
},
onValidateQuery: (): void => {
if (currentVal === '') {
return;
}
const onSelectLabelValue = (): void => {
if (currentVal !== '') {
setStaging((prevState) => [...prevState, currentVal]);
} else {
return;
}
setCurrentVal('');
};
const generatedQuery = createQuery([...staging, currentVal]);
const onValidateQuery = (): void => {
if (currentVal === '') {
return;
}
if (generatedQuery) {
dispatchChanges([...queries, generatedQuery]);
setStaging([]);
setCurrentVal('');
send('RESET');
}
},
},
});
const generatedQuery = createQuery([...staging, currentVal]);
if (generatedQuery) {
dispatchChanges([...queries, generatedQuery]);
setStaging([]);
setCurrentVal('');
setStep('Idle');
}
};
const send = (event: LabelEvent): void => {
if (event === 'RESET') {
setStep('Idle');
return;
}
if (event === 'NEXT') {
if (step === 'Idle') {
setStep('LabelKey');
} else if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
return;
}
if (event === 'onBlur') {
if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
}
};
const handleFocus = (): void => {
if (state.value === 'Idle') {
if (step === 'Idle') {
send('NEXT');
}
};
@@ -79,7 +102,7 @@ function LabelSelect({
if (staging.length === 1 && staging[0] !== undefined) {
send('onBlur');
}
}, [send, staging]);
}, [staging]);
useEffect(() => {
handleBlur();
@@ -115,14 +138,14 @@ function LabelSelect({
});
};
const renderPlaceholder = useCallback((): string => {
if (state.value === 'LabelKey') {
if (step === 'LabelKey') {
return 'Enter a label key then press ENTER.';
}
if (state.value === 'LabelValue') {
if (step === 'LabelValue') {
return `Enter a value for label key(${staging[0]}) then press ENTER.`;
}
return t('placeholder_label_key_pair');
}, [t, state, staging]);
}, [t, step, staging]);
return (
<SearchContainer isDarkMode={isDarkMode} disabled={false}>
<div style={{ display: 'inline-flex', flexWrap: 'wrap' }}>
@@ -148,7 +171,7 @@ function LabelSelect({
if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') {
send('NEXT');
}
if (state.value === 'Idle') {
if (step === 'Idle') {
send('NEXT');
}
}}
@@ -159,7 +182,7 @@ function LabelSelect({
onBlur={handleBlur}
/>
{queries.length || staging.length || currentVal ? (
{queries.length > 0 || staging.length > 0 || currentVal ? (
<Button
onClick={handleClearAll}
icon={<CloseCircleFilled />}

View File

@@ -1,51 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createMachine } from 'xstate';
export const DashboardSearchAndFilter = createMachine({
tsTypes: {} as import('./Dashboard.machine.typegen').Typegen0,
initial: 'Idle',
states: {
Category: {
on: {
NEXT: {
actions: 'onSelectOperator',
target: 'Operator',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
},
},
Operator: {
on: {
NEXT: {
actions: 'onSelectValue',
target: 'Value',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
},
},
Value: {
on: {
onBlur: {
actions: ['onValidateQuery', 'onBlurPurge'],
target: 'Idle',
},
},
},
Idle: {
on: {
NEXT: {
actions: 'onSelectCategory',
description: 'Select Category',
target: 'Category',
},
},
},
},
id: 'Dashboard Search And Filter',
});

View File

@@ -1,32 +0,0 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true;
eventsCausingActions: {
onSelectOperator: 'NEXT';
onBlurPurge: 'onBlur';
onSelectValue: 'NEXT';
onValidateQuery: 'onBlur';
onSelectCategory: 'NEXT';
};
internalEvents: {
'xstate.init': { type: 'xstate.init' };
};
invokeSrcNameMap: {};
missingImplementations: {
actions:
| 'onSelectOperator'
| 'onBlurPurge'
| 'onSelectValue'
| 'onValidateQuery'
| 'onSelectCategory';
services: never;
guards: never;
delays: never;
};
eventsCausingServices: {};
eventsCausingGuards: {};
eventsCausingDelays: {};
matchesStates: 'Category' | 'Operator' | 'Value' | 'Idle';
tags: never;
}

View File

@@ -1,21 +0,0 @@
import { QueryChipContainer, QueryChipItem } from './styles';
import { IQueryStructure } from './types';
export default function QueryChip({
queryData,
onRemove,
}: {
queryData: IQueryStructure;
onRemove: (id: string) => void;
}): JSX.Element {
const { category, operator, value, id } = queryData;
return (
<QueryChipContainer>
<QueryChipItem>{category}</QueryChipItem>
<QueryChipItem>{operator}</QueryChipItem>
<QueryChipItem closable onClose={(): void => onRemove(id)}>
{Array.isArray(value) ? value.join(', ') : null}
</QueryChipItem>
</QueryChipContainer>
);
}

View File

@@ -1,64 +0,0 @@
import { Dashboard } from 'types/api/dashboard/getAll';
import { v4 as uuid } from 'uuid';
import { TOperator } from '../types';
import { executeSearchQueries } from '../utils';
describe('executeSearchQueries', () => {
const firstDashboard: Dashboard = {
id: uuid(),
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'first dashboard',
variables: {},
},
};
const secondDashboard: Dashboard = {
id: uuid(),
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'second dashboard',
variables: {},
},
};
const thirdDashboard: Dashboard = {
id: uuid(),
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'third dashboard (with special characters +?\\)',
variables: {},
},
};
const dashboards = [firstDashboard, secondDashboard, thirdDashboard];
it('should filter dashboards based on title', () => {
const query = {
category: 'title',
id: 'someid',
operator: '=' as TOperator,
value: 'first dashboard',
};
expect(executeSearchQueries([query], dashboards)).toEqual([firstDashboard]);
});
it('should filter dashboards with special characters', () => {
const query = {
category: 'title',
id: 'someid',
operator: '=' as TOperator,
value: 'third dashboard (with special characters +?\\)',
};
expect(executeSearchQueries([query], dashboards)).toEqual([thirdDashboard]);
});
});

View File

@@ -1,212 +0,0 @@
import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { CloseCircleFilled } from '@ant-design/icons';
// eslint-disable-next-line no-restricted-imports
import { useMachine } from '@xstate/react';
import { Button, RefSelectProps, Select } from 'antd';
import history from 'lib/history';
import { filter, map } from 'lodash-es';
import { Dashboard } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';
import { DashboardSearchAndFilter } from './Dashboard.machine';
import QueryChip from './QueryChip';
import { QueryChipItem, SearchContainer } from './styles';
import { IOptionsData, IQueryStructure, TCategory, TOperator } from './types';
import {
convertQueriesToURLQuery,
convertURLQueryStringToQuery,
executeSearchQueries,
OptionsSchemas,
OptionsValueResolution,
} from './utils';
function SearchFilter({
searchData,
filterDashboards,
}: {
searchData: Dashboard[];
filterDashboards: (filteredDashboards: Dashboard[]) => void;
}): JSX.Element {
const [category, setCategory] = useState<TCategory>();
const [optionsData, setOptionsData] = useState<IOptionsData>(
OptionsSchemas.attribute,
);
const selectRef = useRef() as MutableRefObject<RefSelectProps>;
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [staging, setStaging] = useState<string[] | string[][] | unknown[]>([]);
const [queries, setQueries] = useState<IQueryStructure[]>([]);
useEffect(() => {
const searchQueryString = new URLSearchParams(history.location.search).get(
'search',
);
if (searchQueryString) {
setQueries(convertURLQueryStringToQuery(searchQueryString) || []);
}
}, []);
useEffect(() => {
filterDashboards(executeSearchQueries(queries, searchData));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queries, searchData]);
const updateURLWithQuery = useCallback(
(inputQueries?: IQueryStructure[]): void => {
history.push({
pathname: history.location.pathname,
search:
inputQueries || queries
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
: '',
});
},
[queries],
);
useEffect(() => {
if (Array.isArray(queries) && queries.length > 0) {
updateURLWithQuery();
}
}, [queries, updateURLWithQuery]);
const [state, send] = useMachine(DashboardSearchAndFilter, {
actions: {
onSelectCategory: () => {
setOptionsData(OptionsSchemas.attribute);
},
onSelectOperator: () => {
setOptionsData(OptionsSchemas.operator);
},
onSelectValue: () => {
setOptionsData(
OptionsValueResolution(category as TCategory, searchData) as IOptionsData,
);
},
onBlurPurge: () => {
setSelectedValues([]);
setStaging([]);
},
onValidateQuery: () => {
if (staging.length <= 2 && selectedValues.length === 0) {
return;
}
setQueries([
...queries,
{
id: uuidv4(),
category: staging[0] as string,
operator: staging[1] as TOperator,
value: selectedValues,
},
]);
},
},
});
const nextState = (): void => {
send('NEXT');
};
const removeQueryById = (queryId: string): void => {
setQueries((queries) => {
const updatedQueries = filter(queries, ({ id }) => id !== queryId);
updateURLWithQuery(updatedQueries);
return updatedQueries;
});
};
const handleChange = (value: never | string[]): void => {
if (!value) {
return;
}
if (optionsData.mode) {
setSelectedValues(value.filter(Boolean));
return;
}
setStaging([...staging, value]);
if (state.value === 'Category') {
setCategory(`${value}`.toLowerCase() as TCategory);
}
nextState();
setSelectedValues([]);
};
const handleFocus = (): void => {
if (state.value === 'Idle') {
send('NEXT');
selectRef.current?.focus();
}
};
const handleBlur = (): void => {
send('onBlur');
selectRef?.current?.blur();
};
const clearQueries = (): void => {
setQueries([]);
history.push({
pathname: history.location.pathname,
search: ``,
});
};
return (
<SearchContainer>
<div>
{map(queries, (query) => (
<QueryChip key={query.id} queryData={query} onRemove={removeQueryById} />
))}
{map(staging, (value) => (
<QueryChipItem key={JSON.stringify(value)}>
{value as string}
</QueryChipItem>
))}
</div>
{optionsData && (
<Select
placeholder={
!queries.length &&
!staging.length &&
!selectedValues.length &&
'Search or Filter results'
}
size="small"
ref={selectRef}
mode={optionsData.mode as 'tags' | 'multiple'}
style={{ flex: 1 }}
onChange={handleChange}
bordered={false}
suffixIcon={null}
value={selectedValues}
onFocus={handleFocus}
onBlur={handleBlur}
showSearch
>
{optionsData.options &&
Array.isArray(optionsData.options) &&
optionsData.options.map(
(optionItem): JSX.Element => (
<Select.Option
key={(optionItem.value as string) || (optionItem.name as string)}
value={optionItem.value || optionItem.name}
>
{optionItem.name}
</Select.Option>
),
)}
</Select>
)}
{queries && queries.length > 0 && (
<Button icon={<CloseCircleFilled />} type="text" onClick={clearQueries} />
)}
</SearchContainer>
);
}
export default SearchFilter;

View File

@@ -1,27 +0,0 @@
import { grey } from '@ant-design/colors';
import { Tag } from 'antd';
import styled from 'styled-components';
export const SearchContainer = styled.div`
width: 100%;
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0;
margin: 1rem 0;
border: 1px solid #ccc5;
`;
export const QueryChipContainer = styled.span`
display: flex;
align-items: center;
margin-right: 0.5rem;
&:hover {
& > * {
background: ${grey.primary}44;
}
}
`;
export const QueryChipItem = styled(Tag)`
margin-right: 0.1rem;
`;

View File

@@ -1,18 +0,0 @@
export type TOperator = '=' | '!=';
export type TCategory = 'title' | 'description' | 'tags';
export interface IQueryStructure {
category: string;
id: string;
operator: TOperator;
value: string | string[];
}
interface IOptions {
name: string;
value?: string;
}
export interface IOptionsData {
mode: undefined | 'tags' | 'multiple';
options: IOptions[] | [];
}

View File

@@ -1,153 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { decode, encode } from 'js-base64';
import { flattenDeep, map, uniqWith } from 'lodash-es';
import { Dashboard } from 'types/api/dashboard/getAll';
import { IOptionsData, IQueryStructure, TCategory, TOperator } from './types';
export const convertQueriesToURLQuery = (
queries: IQueryStructure[],
): string => {
if (!queries || !queries.length) {
return '';
}
return encode(JSON.stringify(queries));
};
export const convertURLQueryStringToQuery = (
queryString: string,
): IQueryStructure[] => JSON.parse(decode(queryString));
export const resolveOperator = (
result: unknown,
operator: TOperator,
): boolean => {
if (operator === '!=') {
return !result;
}
if (operator === '=') {
return !!result;
}
return !!result;
};
export const executeSearchQueries = (
queries: IQueryStructure[] = [],
searchData: Dashboard[] = [],
): Dashboard[] => {
if (!searchData.length || !queries.length) {
return searchData;
}
const escapeRegExp = (regExp: string): string =>
regExp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
queries.forEach((query: IQueryStructure) => {
const { operator } = query;
let { value } = query;
const categoryLowercase: TCategory = `${query.category}`.toLowerCase() as
| 'title'
| 'description';
value = flattenDeep([value]);
searchData = searchData.filter(({ data: searchPayload }: Dashboard) => {
try {
const searchSpace =
flattenDeep([searchPayload[categoryLowercase]]).filter(Boolean) || null;
if (!searchSpace || !searchSpace.length) {
return resolveOperator(false, operator);
}
for (const searchSpaceItem of searchSpace) {
if (searchSpaceItem) {
for (const queryValue of value) {
if (searchSpaceItem.match(escapeRegExp(queryValue))) {
return resolveOperator(true, operator);
}
}
}
}
} catch (error) {
console.error(error);
}
return resolveOperator(false, operator);
});
});
return searchData;
};
export const OptionsSchemas = {
attribute: {
mode: undefined,
options: [
{
name: 'Title',
},
{
name: 'Description',
},
{
name: 'Tags',
},
],
},
operator: {
mode: undefined,
options: [
{
value: '=',
name: 'Equal',
},
{
name: 'Not Equal',
value: '!=',
},
],
},
};
export function OptionsValueResolution(
category: TCategory,
searchData: Dashboard[],
): Record<string, unknown> | IOptionsData {
const OptionsValueSchema = {
title: {
mode: 'tags',
options: uniqWith(
map(searchData, (searchItem) => ({ name: searchItem.data.title })),
(prev, next) => prev.name === next.name,
),
},
description: {
mode: 'tags',
options: uniqWith(
map(searchData, (searchItem) =>
searchItem.data.description
? {
name: searchItem.data.description,
value: searchItem.data.description,
}
: null,
).filter(Boolean),
(prev, next) => prev?.name === next?.name,
),
},
tags: {
mode: 'tags',
options: uniqWith(
map(
flattenDeep(
// @ts-ignore
map(searchData, (searchItem) => searchItem.data.tags).filter(Boolean),
),
(tag) => ({ name: tag }),
),
(prev, next) => prev.name === next.name,
),
},
};
return (
OptionsValueSchema[category] ||
({ mode: undefined, options: [] } as IOptionsData)
);
}

View File

@@ -1,7 +1,5 @@
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { useMachine } from '@xstate/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -12,7 +10,6 @@ import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import { whilelistedKeys } from './config';
import { ResourceContext } from './context';
import { ResourceAttributesFilterMachine } from './machine';
import {
IResourceAttribute,
IResourceAttributeProps,
@@ -28,6 +25,9 @@ import {
OperatorSchema,
} from './utils';
type ResourceStep = 'Idle' | 'TagKey' | 'Operator' | 'TagValue';
type ResourceEvent = 'NEXT' | 'onBlur' | 'RESET';
function ResourceProvider({ children }: Props): JSX.Element {
const { pathname } = useLocation();
const [loading, setLoading] = useState(true);
@@ -36,6 +36,7 @@ function ResourceProvider({ children }: Props): JSX.Element {
const [queries, setQueries] = useState<IResourceAttribute[]>(
getResourceAttributeQueriesFromURL(),
);
const [step, setStep] = useState<ResourceStep>('Idle');
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
@@ -75,64 +76,79 @@ function ResourceProvider({ children }: Props): JSX.Element {
[pathname, safeNavigate, urlQuery],
);
const [state, send] = useMachine(ResourceAttributesFilterMachine, {
actions: {
onSelectTagKey: () => {
handleLoading(true);
GetTagKeys(dotMetricsEnabled)
.then((tagKeys) => {
const options = mappingWithRoutesAndKeys(pathname, tagKeys);
const loadTagKeys = (): void => {
handleLoading(true);
GetTagKeys(dotMetricsEnabled)
.then((tagKeys) => {
const options = mappingWithRoutesAndKeys(pathname, tagKeys);
setOptionsData({ options, mode: undefined });
})
.finally(() => {
handleLoading(false);
});
};
setOptionsData({
options,
mode: undefined,
});
})
.finally(() => {
handleLoading(false);
});
},
onSelectOperator: () => {
setOptionsData({ options: OperatorSchema, mode: undefined });
},
onSelectTagValue: () => {
handleLoading(true);
const loadTagValues = (): void => {
handleLoading(true);
GetTagValues(staging[0])
.then((tagValuesOptions) =>
setOptionsData({ options: tagValuesOptions, mode: 'multiple' }),
)
.finally(() => {
handleLoading(false);
});
};
GetTagValues(staging[0])
.then((tagValuesOptions) =>
setOptionsData({ options: tagValuesOptions, mode: 'multiple' }),
)
.finally(() => {
handleLoading(false);
});
},
onBlurPurge: () => {
setSelectedQueries([]);
setStaging([]);
},
onValidateQuery: (): void => {
if (staging.length < 2 || selectedQuery.length === 0) {
return;
}
const handleNext = (): void => {
if (step === 'Idle') {
loadTagKeys();
setStep('TagKey');
} else if (step === 'TagKey') {
setOptionsData({ options: OperatorSchema, mode: undefined });
setStep('Operator');
} else if (step === 'Operator') {
loadTagValues();
setStep('TagValue');
}
};
const generatedQuery = createQuery([...staging, selectedQuery]);
const handleOnBlur = (): void => {
if (step === 'TagValue' && staging.length >= 2 && selectedQuery.length > 0) {
const generatedQuery = createQuery([...staging, selectedQuery]);
if (generatedQuery) {
dispatchQueries([...queries, generatedQuery]);
}
}
if (step !== 'Idle') {
setSelectedQueries([]);
setStaging([]);
setStep('Idle');
}
};
if (generatedQuery) {
dispatchQueries([...queries, generatedQuery]);
}
},
},
});
const send = (event: ResourceEvent): void => {
if (event === 'RESET') {
setStep('Idle');
return;
}
if (event === 'NEXT') {
handleNext();
return;
}
if (event === 'onBlur') {
handleOnBlur();
}
};
const handleFocus = useCallback((): void => {
if (state.value === 'Idle') {
if (step === 'Idle') {
send('NEXT');
}
}, [send, state.value]);
}, [step]);
const handleBlur = useCallback((): void => {
send('onBlur');
}, [send]);
}, [step, staging, selectedQuery, queries, dispatchQueries]);
const handleChange = useCallback(
(value: string): void => {
@@ -145,7 +161,7 @@ function ResourceProvider({ children }: Props): JSX.Element {
setSelectedQueries([...value]);
},
[optionsData.mode, send],
[optionsData.mode, step, staging, dotMetricsEnabled, pathname],
);
const handleEnvironmentChange = useCallback(
@@ -166,9 +182,9 @@ function ResourceProvider({ children }: Props): JSX.Element {
dispatchQueries([...queriesCopy]);
}
send('RESET');
setStep('Idle');
},
[dispatchQueries, dotMetricsEnabled, queries, send],
[dispatchQueries, dotMetricsEnabled, queries],
);
const handleClose = useCallback(
@@ -179,12 +195,12 @@ function ResourceProvider({ children }: Props): JSX.Element {
);
const handleClearAll = useCallback(() => {
send('RESET');
setStep('Idle');
dispatchQueries([]);
setStaging([]);
setQueries([]);
setOptionsData({ mode: undefined, options: [] });
}, [dispatchQueries, send]);
}, [dispatchQueries]);
const getVisibleQueries = useMemo(() => {
if (pathname === ROUTES.SERVICE_MAP) {

View File

@@ -1,63 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createMachine } from 'xstate';
export const ResourceAttributesFilterMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBECGsAWAjA9qgThAAQDKYBAxhkQIIB2xAYgJYA2ALmPgHQAqqUANJgAngGIAcgFEAGrwDaABgC6iUAAccsZu2Y46akAA9EATkUB2bgEYAbBYsBWWwA5HAFkW3F7gDQgRRABaU3duFwsXAGZbWwAmF3co01jTAF80-zRMXAJiMkpqeiY2Th5+IVExfQAhVgBXfCVVJBBNbV19QxMEcys7B2c3T28-AOC4xUduKItrSbiEuNMo6zcMrPRsPEJScnwqWgYiFg4uPgFhcQAlKRIpBRVDdp09A1aevpt7J1cPLx8-kCCCCcUcURmcwWSxWa0cGxA2W2eT2hSOJTOPAA8uouKh2Dh8JJZI8WhotK8uh9EPM4tYZl4IrZHNY1rZrEDgqFwpEoi43HEnMt3NYEUjcrsCgcisdTmVuDi8QSibUGk0nq0Xp13qAerT6VFGRZmayXOzOSDJtNZrT3I44t5bHaLGKthL8vtDsUTqVzor8PjCWJbvdSc8KdrujTFgajSa2RzxpbwZDbfbHc7XTkdh60d65ecKgA1VANMDVOh1RrNcMdN6GYFBayOKw2xZ2h1eZ3+PX2+mxFzWEWmFymBxRLPIyWemUY+XF0v1cshh41zUR+vUhDNuncAdD6wjscWKIW0FTVPt9NdluT92o6Xon2Y7gASQgrHL0jka-JdapuqIPEcTcIoihxHyTh2Pa-JntyETRO4ngig6yTuBkmQgHQOAQHAhjijmD5erKvr4LWlI6sYiDJIo3Aiieh7Gk4UynkmQRRJ44TARYijJC4AJRBOmEESiUrEXOhaXKI5GRluPG0SkI7uIKhr2vaZ7Nq2cxrGByQWKYpiisJbqEWJs7PvK-qBmR67-pReq6aB1g+DEkEcaYcQaS2l7gTCqzrMZ2aiTOT4FuUAglmWMmboB258hCESmNeLgQR4jheVp8y+SlsIBZsQXTnmJEvu+n7RQBVEIEkLh0dYDFjvYjgsRlqY6bxY4GUZGRAA */
createMachine({
tsTypes: {} as import('./machine.typegen').Typegen0,
initial: 'Idle',
states: {
TagKey: {
on: {
NEXT: {
actions: 'onSelectOperator',
target: 'Operator',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
RESET: {
target: 'Idle',
},
},
},
Operator: {
on: {
NEXT: {
actions: 'onSelectTagValue',
target: 'TagValue',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
RESET: {
target: 'Idle',
},
},
},
TagValue: {
on: {
onBlur: {
actions: ['onValidateQuery', 'onBlurPurge'],
target: 'Idle',
},
RESET: {
target: 'Idle',
},
},
},
Idle: {
on: {
NEXT: {
actions: 'onSelectTagKey',
description: 'Select Category',
target: 'TagKey',
},
},
},
},
predictableActionArguments: true,
id: 'ResourceAttributesFilterMachine',
});

View File

@@ -1,32 +0,0 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true;
internalEvents: {
'xstate.init': { type: 'xstate.init' };
};
invokeSrcNameMap: {};
missingImplementations: {
actions:
| 'onBlurPurge'
| 'onSelectOperator'
| 'onSelectTagKey'
| 'onSelectTagValue'
| 'onValidateQuery';
delays: never;
guards: never;
services: never;
};
eventsCausingActions: {
onBlurPurge: 'onBlur';
onSelectOperator: 'NEXT';
onSelectTagKey: 'NEXT';
onSelectTagValue: 'NEXT';
onValidateQuery: 'onBlur';
};
eventsCausingDelays: {};
eventsCausingGuards: {};
eventsCausingServices: {};
matchesStates: 'Idle' | 'Operator' | 'TagKey' | 'TagValue';
tags: never;
}

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"
@@ -7031,14 +7031,6 @@
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xstate/react@^3.0.0":
version "3.2.2"
resolved "https://registry.npmjs.org/@xstate/react/-/react-3.2.2.tgz"
integrity sha512-feghXWLedyq8JeL13yda3XnHPZKwYDN5HPBLykpLeuNpr9178tQd2/3d0NrH6gSd0sG5mLuLeuD+ck830fgzLQ==
dependencies:
use-isomorphic-layout-effect "^1.1.2"
use-sync-external-store "^1.0.0"
"@zxing/text-encoding@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
@@ -19157,11 +19149,6 @@ use-callback-ref@^1.3.3:
dependencies:
tslib "^2.0.0"
use-isomorphic-layout-effect@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-memo-one@^1.1.1:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
@@ -19175,11 +19162,6 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
@@ -19723,11 +19705,6 @@ xss@^1.0.14:
commander "^2.20.3"
cssfilter "0.0.10"
xstate@^4.31.0:
version "4.37.2"
resolved "https://registry.npmjs.org/xstate/-/xstate-4.37.2.tgz"
integrity sha512-Qm337O49CRTZ3PRyRuK6b+kvI+D3JGxXIZCTul+xEsyFCVkTFDt5jixaL1nBWcUBcaTQ9um/5CRGVItPi7fveg==
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"

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")}