mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-23 04:10:29 +01:00
Compare commits
3 Commits
platform-p
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43c15f8ed | ||
|
|
77f06094bd | ||
|
|
47078e2789 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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[] | [];
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,6 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 dashboard‑level 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "*"),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user