mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-13 08:42:08 +00:00
Compare commits
1 Commits
debug_time
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8040740499 |
@@ -31,9 +31,22 @@ func (server *Server) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
subject := ""
|
||||
switch claims.Principal {
|
||||
case authtypes.PrincipalUser.StringValue():
|
||||
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = user
|
||||
case authtypes.PrincipalServiceAccount.StringValue():
|
||||
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = serviceAccount
|
||||
}
|
||||
|
||||
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
|
||||
|
||||
@@ -29,6 +29,7 @@ import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
@@ -383,26 +384,28 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -297,12 +297,7 @@ function CustomTimePicker({
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
const handleInputPressEnter = (
|
||||
event?: React.KeyboardEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const handleInputPressEnter = (): void => {
|
||||
// check if the entered time is in the format of 1m, 2h, 3d, 4w
|
||||
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ const mockSafeNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -64,7 +69,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -105,7 +110,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -144,7 +149,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
|
||||
@@ -5,6 +5,7 @@ import NewWidget from 'container/NewWidget';
|
||||
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import i18n from 'ReactI18';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -103,13 +104,15 @@ describe('LogsPanelComponent', () => {
|
||||
const renderComponent = async (): Promise<void> => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,15 +8,28 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import {
|
||||
getDefaultWidgetData,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
} from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
// import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { defaultTo, isUndefined } from 'lodash-es';
|
||||
import { Atom, Terminal } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
getNextWidgets,
|
||||
getPreviousWidgets,
|
||||
getSelectedWidgetIndex,
|
||||
} from 'providers/Dashboard/util';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
|
||||
@@ -27,25 +40,77 @@ function QuerySection({
|
||||
selectedGraph,
|
||||
queryRangeKey,
|
||||
isLoadingQueries,
|
||||
selectedWidget,
|
||||
dashboardVersion,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
}: QueryProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
handleRunQuery: handleRunQueryFromQueryBuilder,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { selectedDashboard, setSelectedDashboard } = useDashboard();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { widgets } = selectedDashboard?.data || {};
|
||||
|
||||
const getWidget = useCallback(() => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
return defaultTo(
|
||||
widgets?.find((e) => e.id === widgetId),
|
||||
getDefaultWidgetData(widgetId || '', selectedGraph),
|
||||
);
|
||||
}, [urlQuery, widgets, selectedGraph]);
|
||||
|
||||
const selectedWidget = getWidget() as Widgets;
|
||||
|
||||
const { query } = selectedWidget;
|
||||
|
||||
useShareBuilderUrl({ defaultValue: query });
|
||||
|
||||
const handleStageQuery = useCallback(
|
||||
(query: Query): void => {
|
||||
if (selectedDashboard === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedWidgetIndex = getSelectedWidgetIndex(
|
||||
selectedDashboard,
|
||||
selectedWidget.id,
|
||||
);
|
||||
|
||||
const previousWidgets = getPreviousWidgets(
|
||||
selectedDashboard,
|
||||
selectedWidgetIndex,
|
||||
);
|
||||
|
||||
const nextWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
|
||||
|
||||
setSelectedDashboard({
|
||||
...selectedDashboard,
|
||||
data: {
|
||||
...selectedDashboard?.data,
|
||||
widgets: [
|
||||
...previousWidgets,
|
||||
{
|
||||
...selectedWidget,
|
||||
query,
|
||||
},
|
||||
...nextWidgets,
|
||||
],
|
||||
},
|
||||
});
|
||||
handleRunQueryFromQueryBuilder();
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
selectedWidget,
|
||||
setSelectedDashboard,
|
||||
handleRunQueryFromQueryBuilder,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(qCategory: string): void => {
|
||||
const currentQueryType = qCategory as EQueryType;
|
||||
@@ -58,16 +123,19 @@ function QuerySection({
|
||||
);
|
||||
|
||||
const handleRunQuery = (): void => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
const isNewPanel = isUndefined(widgets?.find((e) => e.id === widgetId));
|
||||
|
||||
logEvent('Panel Edit: Stage and run query', {
|
||||
dataSource: currentQuery.builder?.queryData?.[0]?.dataSource,
|
||||
panelType: selectedWidget.panelTypes,
|
||||
queryType: currentQuery.queryType,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
isNewPanel,
|
||||
});
|
||||
handleRunQueryFromQueryBuilder();
|
||||
handleStageQuery(currentQuery);
|
||||
};
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
@@ -96,7 +164,7 @@ function QuerySection({
|
||||
panelType={selectedGraph}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={selectedGraph !== PANEL_TYPES.LIST}
|
||||
version={dashboardVersion || 'v3'}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
@@ -136,7 +204,7 @@ function QuerySection({
|
||||
queryComponents,
|
||||
selectedGraph,
|
||||
filterConfigs,
|
||||
dashboardVersion,
|
||||
selectedDashboard?.data?.version,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
@@ -193,11 +261,6 @@ interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
dashboardName?: string;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
|
||||
@@ -30,8 +30,6 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
selectedDashboard,
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
@@ -77,11 +75,6 @@ function LeftContainer({
|
||||
selectedGraph={selectedGraph}
|
||||
queryRangeKey={queryRangeKey}
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={selectedDashboard?.id}
|
||||
dashboardName={selectedDashboard?.data.title}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
{selectedGraph === PANEL_TYPES.LIST && (
|
||||
<ExplorerColumnsRenderer
|
||||
|
||||
@@ -8,6 +8,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -95,7 +96,9 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
<Provider store={createMockStore()}>
|
||||
<AppContext.Provider value={createMockAppContext() as IAppContext}>
|
||||
<ErrorModalProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</DashboardProvider>
|
||||
</ErrorModalProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
|
||||
@@ -310,12 +310,12 @@ describe('Stacking bar in new panel', () => {
|
||||
|
||||
const { container, getByText } = render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider dashboardId="">
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
@@ -356,11 +356,11 @@ describe('when switching to BAR panel type', () => {
|
||||
|
||||
it('should preserve saved stacking value of true', async () => {
|
||||
const { getByTestId, getByText, container } = render(
|
||||
<DashboardProvider dashboardId="">
|
||||
<DashboardProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</DashboardProvider>,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -32,6 +32,8 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
import {
|
||||
clearSelectedRowWidgetId,
|
||||
@@ -81,8 +83,6 @@ import {
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
function NewWidget({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
@@ -90,6 +90,11 @@ function NewWidget({
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
@@ -134,6 +139,8 @@ function NewWidget({
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
|
||||
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -279,10 +286,11 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: selectedWidget.columnWidths,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
contextLinks,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnUnits,
|
||||
currentQuery,
|
||||
@@ -305,8 +313,8 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
selectedWidget.columnWidths,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -552,7 +560,8 @@ function NewWidget({
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
@@ -571,6 +580,7 @@ function NewWidget({
|
||||
preWidgets,
|
||||
updateDashboardMutation,
|
||||
widgets,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
safeNavigate,
|
||||
dashboardId,
|
||||
@@ -620,25 +630,22 @@ function NewWidget({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const isNewPanel = useMemo(() => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId);
|
||||
return isUndefined(selectedWidget);
|
||||
}, [query, widgets]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
||||
|
||||
logEvent('Panel Edit: Save changes', {
|
||||
panelType: selectedWidget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel,
|
||||
isNewPanel: isUndefined(selectWidget),
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel]);
|
||||
}, []);
|
||||
|
||||
const isNewTraceLogsAvailable =
|
||||
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
|
||||
@@ -814,8 +821,6 @@ function NewWidget({
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedDashboard={selectedDashboard}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { IDashboardContext } from 'providers/Dashboard/types';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -10,9 +9,9 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { timePreferance } from './RightContainer/timeItems';
|
||||
|
||||
export interface NewWidgetProps {
|
||||
dashboardId: string;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
selectedGraph: PANEL_TYPES;
|
||||
yAxisUnit: Widgets['yAxisUnit'];
|
||||
fillSpans: Widgets['fillSpans'];
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,8 +34,6 @@ export interface WidgetGraphProps {
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
|
||||
@@ -107,6 +107,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryRangeMutation,
|
||||
dashboardVariables,
|
||||
dashboardDynamicVariables,
|
||||
selectedDashboard?.data.version,
|
||||
widget,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
function DashboardPageWithProvider(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
return (
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardPage />
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageWithProvider;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,91 +1,50 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { generatePath, useLocation, useParams } from 'react-router-dom';
|
||||
import { Card, Typography } from 'antd';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import NewWidget from 'container/NewWidget';
|
||||
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
function DashboardWidget(): JSX.Element | null {
|
||||
const { dashboardId } = useParams<{
|
||||
dashboardId: string;
|
||||
}>();
|
||||
const [widgetId] = useQueryState('widgetId');
|
||||
const [graphType] = useQueryState(
|
||||
'graphType',
|
||||
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)),
|
||||
);
|
||||
|
||||
const { search } = useLocation();
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const [selectedGraph, setSelectedGraph] = useState<PANEL_TYPES>();
|
||||
|
||||
const { selectedDashboard, dashboardResponse } = useDashboard();
|
||||
|
||||
const params = useUrlQuery();
|
||||
|
||||
const widgetId = params.get('widgetId');
|
||||
const { data } = selectedDashboard || {};
|
||||
const { widgets } = data || {};
|
||||
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId) as Widgets;
|
||||
|
||||
useEffect(() => {
|
||||
if (!graphType || !widgetId) {
|
||||
const params = new URLSearchParams(search);
|
||||
const graphType = params.get('graphType') as PANEL_TYPES | null;
|
||||
|
||||
if (graphType === null) {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
} else if (!dashboardId) {
|
||||
safeNavigate(ROUTES.HOME);
|
||||
} else {
|
||||
setSelectedGraph(graphType);
|
||||
}
|
||||
}, [graphType, widgetId, dashboardId, safeNavigate]);
|
||||
}, [dashboardId, safeNavigate, search]);
|
||||
|
||||
if (!widgetId || !graphType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardWidgetInternal
|
||||
dashboardId={dashboardId}
|
||||
widgetId={widgetId}
|
||||
graphType={graphType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardWidgetInternal({
|
||||
dashboardId,
|
||||
widgetId,
|
||||
graphType,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
widgetId: string;
|
||||
graphType: PANEL_TYPES;
|
||||
}): JSX.Element | null {
|
||||
const {
|
||||
data: dashboardResponse,
|
||||
isFetching: isFetchingDashboardResponse,
|
||||
isError: isErrorDashboardResponse,
|
||||
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
|
||||
enabled: true,
|
||||
queryFn: async () =>
|
||||
await getDashboard({
|
||||
id: dashboardId,
|
||||
}),
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSuccess: (response) => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: response.data.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selectedDashboard = useMemo(() => dashboardResponse?.data, [
|
||||
dashboardResponse?.data,
|
||||
]);
|
||||
|
||||
if (isFetchingDashboardResponse) {
|
||||
if (selectedGraph === undefined || dashboardResponse.isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (isErrorDashboardResponse) {
|
||||
if (dashboardResponse.isError) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography>{SOMETHING_WENT_WRONG}</Typography>
|
||||
@@ -95,11 +54,16 @@ function DashboardWidgetInternal({
|
||||
|
||||
return (
|
||||
<NewWidget
|
||||
dashboardId={dashboardId}
|
||||
selectedGraph={graphType}
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
selectedDashboard={selectedDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DashboardWidgetPageParams {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export default DashboardWidget;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
@@ -18,6 +19,11 @@ jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
@@ -41,7 +47,9 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=asgard&order=stones&page=1']}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -63,7 +71,9 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=createdAt&order=descend&page=1']}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -82,7 +92,9 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -123,7 +135,9 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -150,7 +164,9 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -180,7 +196,9 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardsList />
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import locked from 'api/v1/dashboards/id/lock';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
@@ -81,11 +83,14 @@ export const DashboardContext = createContext<IDashboardContext>({
|
||||
setColumnWidths: () => {},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function DashboardProvider({
|
||||
children,
|
||||
dashboardId,
|
||||
}: PropsWithChildren<{ dashboardId: string }>): JSX.Element {
|
||||
}: PropsWithChildren): JSX.Element {
|
||||
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
|
||||
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
|
||||
@@ -95,6 +100,11 @@ export function DashboardProvider({
|
||||
setDashboardQueryRangeCalled,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const isDashboardPage = useRouteMatch<Props>({
|
||||
path: ROUTES.DASHBOARD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
@@ -105,6 +115,11 @@ export function DashboardProvider({
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
const isDashboardWidgetPage = useRouteMatch<Props>({
|
||||
path: ROUTES.DASHBOARD_WIDGET,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const [layouts, setLayouts] = useState<Layout[]>([]);
|
||||
|
||||
const [panelMap, setPanelMap] = useState<
|
||||
@@ -113,6 +128,11 @@ export function DashboardProvider({
|
||||
|
||||
const { isLoggedIn } = useAppContext();
|
||||
|
||||
const dashboardId =
|
||||
(isDashboardPage
|
||||
? isDashboardPage.params.dashboardId
|
||||
: isDashboardWidgetPage?.params.dashboardId) || '';
|
||||
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
|
||||
@@ -247,11 +267,12 @@ export function DashboardProvider({
|
||||
const dashboardResponse = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
isDashboardPage?.params,
|
||||
dashboardId,
|
||||
globalTime.isAutoRefreshDisabled,
|
||||
],
|
||||
{
|
||||
enabled: !!dashboardId && isLoggedIn,
|
||||
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
||||
queryFn: async () => {
|
||||
setIsDashboardFetching(true);
|
||||
try {
|
||||
@@ -371,7 +392,11 @@ export function DashboardProvider({
|
||||
|
||||
useEffect(() => {
|
||||
// make the call on tab visibility only if the user is on dashboard / widget page
|
||||
if (isVisible && updatedTimeRef.current && !!dashboardId) {
|
||||
if (
|
||||
isVisible &&
|
||||
updatedTimeRef.current &&
|
||||
(!!isDashboardPage || !!isDashboardWidgetPage)
|
||||
) {
|
||||
dashboardResponse.refetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -2,10 +2,11 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import { DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -18,28 +19,30 @@ jest.mock('api/v1/dashboards/id/get');
|
||||
jest.mock('api/v1/dashboards/id/lock');
|
||||
const mockGetDashboard = jest.mocked(getDashboard);
|
||||
|
||||
// Mock useRouteMatch to simulate different route scenarios
|
||||
const mockUseRouteMatch = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: (): any => mockUseRouteMatch(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock only the essential dependencies for Dashboard provider
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): {
|
||||
isLoggedIn: boolean;
|
||||
user: { email: string; role: string };
|
||||
} => ({
|
||||
useAppContext: (): any => ({
|
||||
isLoggedIn: true,
|
||||
user: { email: 'test@example.com', role: 'ADMIN' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: jest.fn(),
|
||||
}),
|
||||
useErrorModal: (): any => ({ showErrorModal: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -57,10 +60,11 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
function TestComponent(): JSX.Element {
|
||||
const { dashboardResponse, selectedDashboard } = useDashboard();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = selectedDashboard?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="dashboard-id">{selectedDashboard?.id}</div>
|
||||
<div data-testid="dashboard-id">{dashboardId}</div>
|
||||
<div data-testid="query-status">{dashboardResponse.status}</div>
|
||||
<div data-testid="is-loading">{dashboardResponse.isLoading.toString()}</div>
|
||||
<div data-testid="is-fetching">
|
||||
@@ -90,15 +94,27 @@ function createTestQueryClient(): QueryClient {
|
||||
|
||||
// Helper to render with dashboard provider
|
||||
function renderWithDashboardProvider(
|
||||
dashboardId = 'test-dashboard-id',
|
||||
): RenderResult {
|
||||
initialRoute = '/dashboard/test-dashboard-id',
|
||||
routeMatchParams?: { dashboardId: string } | null,
|
||||
): any {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initialRoute = dashboardId ? `/dashboard/${dashboardId}` : '/dashboard';
|
||||
|
||||
// Mock the route match
|
||||
mockUseRouteMatch.mockReturnValue(
|
||||
routeMatchParams
|
||||
? {
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${routeMatchParams.dashboardId}`,
|
||||
isExact: true,
|
||||
params: routeMatchParams,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardProvider>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -172,7 +188,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
describe('Query Key Behavior', () => {
|
||||
it('should include route params in query key when on dashboard page', async () => {
|
||||
const dashboardId = 'test-dashboard-id';
|
||||
renderWithDashboardProvider(dashboardId);
|
||||
renderWithDashboardProvider(`/dashboard/${dashboardId}`, { dashboardId });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
|
||||
@@ -187,17 +203,30 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
const newDashboardId = 'new-dashboard-id';
|
||||
|
||||
// First render with initial dashboard ID
|
||||
const { rerender } = renderWithDashboardProvider(initialDashboardId);
|
||||
const { rerender } = renderWithDashboardProvider(
|
||||
`/dashboard/${initialDashboardId}`,
|
||||
{
|
||||
dashboardId: initialDashboardId,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: initialDashboardId });
|
||||
});
|
||||
|
||||
// Rerender with new dashboard ID prop
|
||||
// Change route params to simulate navigation
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${newDashboardId}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: newDashboardId },
|
||||
});
|
||||
|
||||
// Rerender with new route
|
||||
rerender(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${newDashboardId}`]}>
|
||||
<DashboardProvider dashboardId={newDashboardId}>
|
||||
<DashboardProvider>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -212,24 +241,50 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not fetch when no dashboardId is provided', () => {
|
||||
renderWithDashboardProvider('');
|
||||
it('should not fetch when not on dashboard page', () => {
|
||||
// Mock no route match (not on dashboard page)
|
||||
mockUseRouteMatch.mockReturnValue(null);
|
||||
|
||||
renderWithDashboardProvider('/some-other-page', null);
|
||||
|
||||
// Should not call the API
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined route params gracefully', async () => {
|
||||
// Mock route match with undefined params
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: '/dashboard/undefined',
|
||||
isExact: true,
|
||||
params: undefined,
|
||||
});
|
||||
|
||||
renderWithDashboardProvider('/dashboard/undefined');
|
||||
|
||||
// Should not call API when params are undefined
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Behavior', () => {
|
||||
it('should create separate cache entries for different dashboardIds', async () => {
|
||||
it('should create separate cache entries for different route params', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const dashboardId1 = 'dashboard-1';
|
||||
const dashboardId2 = 'dashboard-2';
|
||||
|
||||
// First dashboard
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId1}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: dashboardId1 },
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId1}`]}>
|
||||
<DashboardProvider dashboardId={dashboardId1}>
|
||||
<DashboardProvider>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -240,10 +295,18 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId1 });
|
||||
});
|
||||
|
||||
// Second dashboard
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId2}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: dashboardId2 },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId2}`]}>
|
||||
<DashboardProvider dashboardId={dashboardId2}>
|
||||
<DashboardProvider>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -262,11 +325,13 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(cacheKeys).toHaveLength(2);
|
||||
expect(cacheKeys[0]).toEqual([
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
{ dashboardId: dashboardId1 },
|
||||
dashboardId1,
|
||||
true, // globalTime.isAutoRefreshDisabled
|
||||
]);
|
||||
expect(cacheKeys[1]).toEqual([
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
{ dashboardId: dashboardId2 },
|
||||
dashboardId2,
|
||||
true, // globalTime.isAutoRefreshDisabled
|
||||
]);
|
||||
@@ -283,10 +348,17 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const dashboardId = 'auto-refresh-dashboard';
|
||||
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId}`,
|
||||
isExact: true,
|
||||
params: { dashboardId },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId}`]}>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardProvider>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -303,7 +375,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
.find(
|
||||
(query) =>
|
||||
query.queryKey[0] === REACT_QUERY_KEY.DASHBOARD_BY_ID &&
|
||||
query.queryKey[2] === false,
|
||||
query.queryKey[3] === false,
|
||||
);
|
||||
expect(dashboardQuery).toBeDefined();
|
||||
expect((dashboardQuery as { cacheTime: number }).cacheTime).toBe(
|
||||
@@ -365,7 +437,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
// Empty URL variables - tests initialization flow
|
||||
mockGetUrlVariables.mockReturnValue({});
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -419,7 +493,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['db', 'cache']);
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -479,7 +555,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
|
||||
mockGetUrlVariables.mockReturnValue(urlVariables);
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -515,7 +593,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['api']);
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify normalization was called with the specific values and variable configs
|
||||
@@ -582,7 +662,9 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -624,7 +706,9 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -667,7 +751,9 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -709,7 +795,9 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
|
||||
@@ -30,5 +30,5 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
|
||||
}
|
||||
|
||||
return authtypes.NewIdentity(user.ID, orgID, user.Email, user.Role), nil
|
||||
return authtypes.NewIdentity(user.ID, valuer.UUID{}, authtypes.PrincipalUser, orgID, user.Email, user.Role), nil
|
||||
}
|
||||
|
||||
@@ -97,11 +97,7 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
|
||||
if len(roles) != len(names) {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
roletypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided names: %v", names,
|
||||
)
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, roletypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", names)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
@@ -122,11 +118,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
|
||||
}
|
||||
|
||||
if len(roles) != len(ids) {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
roletypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided ids: %v", ids,
|
||||
)
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, roletypes.ErrCodeRoleNotFound, "not all roles found for the provided ids: %v", ids)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
|
||||
@@ -128,11 +128,23 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
}
|
||||
|
||||
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector, roleSelectors []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subject := ""
|
||||
switch claims.Principal {
|
||||
case authtypes.PrincipalUser.StringValue():
|
||||
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = user
|
||||
case authtypes.PrincipalServiceAccount.StringValue():
|
||||
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = serviceAccount
|
||||
}
|
||||
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -231,6 +231,23 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetFactorAPIKeyByKey(ctx context.Context, key string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.StorableFactorAPIKey)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("key = ?", key).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with key: %s doesn't exist", key)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
|
||||
|
||||
@@ -248,6 +265,25 @@ func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID value
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) ListFactorAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Join("JOIN service_account").
|
||||
JoinOn("service_account.id = factor_api_key.service_account_id").
|
||||
Where("service_account.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, storable *serviceaccounttypes.StorableFactorAPIKey) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
@@ -263,6 +299,30 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateLastObservedAtByKey(ctx context.Context, apiKeyToLastObservedAt []map[string]any) error {
|
||||
values := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewValues(&apiKeyToLastObservedAt)
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
With("update_cte", values).
|
||||
Model((*serviceaccounttypes.StorableFactorAPIKey)(nil)).
|
||||
TableExpr("update_cte").
|
||||
Set("last_observed_at = update_cte.last_observed_at").
|
||||
Where("factor_api_key.key = update_cte.key").
|
||||
Where("factor_api_key.service_account_id = update_cte.service_account_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
|
||||
@@ -158,7 +158,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
|
||||
}
|
||||
|
||||
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, user.OrgID, user.Email, user.Role), map[string]string{})
|
||||
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, valuer.UUID{}, authtypes.PrincipalUser, user.OrgID, user.Email, user.Role), map[string]string{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -38,6 +39,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/tokenizer/apikeytokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
@@ -65,6 +67,7 @@ type SigNoz struct {
|
||||
Sharder sharder.Sharder
|
||||
StatsReporter statsreporter.StatsReporter
|
||||
Tokenizer pkgtokenizer.Tokenizer
|
||||
APIKeyTokenizer pkgtokenizer.Tokenizer
|
||||
Authz authz.AuthZ
|
||||
Modules Modules
|
||||
Handlers Handlers
|
||||
@@ -279,6 +282,13 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize api key tokenizer
|
||||
apiKeyStore := implserviceaccount.NewStore(sqlstore)
|
||||
apiKeyTokenizer, err := apikeytokenizer.New(ctx, providerSettings, config.Tokenizer, cache, apiKeyStore, orgGetter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize user getter
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
|
||||
|
||||
@@ -443,6 +453,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("licensing"), licensing),
|
||||
factory.NewNamedService(factory.MustNewName("statsreporter"), statsReporter),
|
||||
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
|
||||
factory.NewNamedService(factory.MustNewName("apikeytokenizer"), apiKeyTokenizer),
|
||||
factory.NewNamedService(factory.MustNewName("authz"), authz),
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService),
|
||||
)
|
||||
@@ -468,6 +479,7 @@ func New(
|
||||
Emailing: emailing,
|
||||
Sharder: sharder,
|
||||
Tokenizer: tokenizer,
|
||||
APIKeyTokenizer: apiKeyTokenizer,
|
||||
Authz: authz,
|
||||
Modules: modules,
|
||||
Handlers: handlers,
|
||||
|
||||
353
pkg/tokenizer/apikeytokenizer/provider.go
Normal file
353
pkg/tokenizer/apikeytokenizer/provider.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package apikeytokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cachetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
emptyOrgID valuer.UUID = valuer.UUID{}
|
||||
)
|
||||
|
||||
const (
|
||||
expectedLastObservedAtCacheEntries int64 = 5000 // 1000 serviceAccounts * Max 5 keys per account
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config tokenizer.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
cache cache.Cache
|
||||
apiKeyStore serviceaccounttypes.Store
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
lastObservedAtCache *ristretto.Cache[string, time.Time]
|
||||
}
|
||||
|
||||
func NewFactory(cache cache.Cache, apiKeyStore serviceaccounttypes.Store, orgGetter organization.Getter) factory.ProviderFactory[tokenizer.Tokenizer, tokenizer.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("apikey"), func(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config) (tokenizer.Tokenizer, error) {
|
||||
return New(ctx, providerSettings, config, cache, apiKeyStore, orgGetter)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config, cache cache.Cache, apiKeyStore serviceaccounttypes.Store, orgGetter organization.Getter) (tokenizer.Tokenizer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/tokenizer/apikeytokenizer")
|
||||
|
||||
// * move these hardcoded values to a config based value when needed
|
||||
lastObservedAtCache, err := ristretto.NewCache(&ristretto.Config[string, time.Time]{
|
||||
NumCounters: 10 * expectedLastObservedAtCacheEntries, // 10x of expected entries
|
||||
MaxCost: 1 << 19, // ~ 512 KB
|
||||
BufferItems: 64,
|
||||
Metrics: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenizer.NewWrappedTokenizer(settings, &provider{
|
||||
config: config,
|
||||
settings: settings,
|
||||
cache: cache,
|
||||
apiKeyStore: apiKeyStore,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
lastObservedAtCache: lastObservedAtCache,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(provider.config.Opaque.GC.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
ctx, span := provider.settings.Tracer().Start(ctx, "tokenizer.LastObservedAt", trace.WithAttributes(attribute.String("tokenizer.provider", "serviceaccount")))
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to get orgs data", "error", err)
|
||||
span.End()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
span.RecordError(err)
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush api keys", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) CreateToken(_ context.Context, _ *authtypes.Identity, _ map[string]string) (*authtypes.Token, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
|
||||
apiKey, err := provider.getOrGetSetAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := apiKey.IsExpired(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := provider.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) RotateToken(_ context.Context, _ string, _ string) (*authtypes.Token, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteToken(_ context.Context, _ string) error {
|
||||
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteTokensByUserID(_ context.Context, _ valuer.UUID) error {
|
||||
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteIdentity(ctx context.Context, serviceAccountID valuer.UUID) error {
|
||||
provider.cache.Delete(ctx, emptyOrgID, identityCacheKey(serviceAccountID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
// flush api keys on stop
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
|
||||
apiKey, err := provider.getOrGetSetAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we can't update the last observed at, we return nil.
|
||||
if err := apiKey.UpdateLastObservedAt(lastObservedAt); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok := provider.lastObservedAtCache.Set(lastObservedAtCacheKey(key, apiKey.ServiceAccountID), lastObservedAt, 24); !ok {
|
||||
provider.settings.Logger().ErrorContext(ctx, "error caching last observed at timestamp", "service_account_id", apiKey.ServiceAccountID)
|
||||
}
|
||||
|
||||
err = provider.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), apiKey, provider.config.Lifetime.Max)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Config() tokenizer.Config {
|
||||
return provider.config
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
apiKeys, err := provider.apiKeyStore.ListFactorAPIKeyByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := make(map[string]any)
|
||||
stats["api_key.count"] = len(apiKeys)
|
||||
|
||||
keyToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(keyToLastObservedAt) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
keyToLastObservedAtMax := keyToLastObservedAt[0]
|
||||
|
||||
if lastObservedAt, ok := keyToLastObservedAtMax["last_observed_at"].(time.Time); ok {
|
||||
if !lastObservedAt.IsZero() {
|
||||
stats["api_key.last_observed_at.max.time"] = lastObservedAt.UTC()
|
||||
stats["api_key.last_observed_at.max.time_unix"] = lastObservedAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListMaxLastObservedAtByOrgID(ctx context.Context, orgID valuer.UUID) (map[valuer.UUID]time.Time, error) {
|
||||
apiKeyToLastObservedAts, err := provider.listLastObservedAtDesc(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxLastObservedAtPerServiceAccountID := make(map[valuer.UUID]time.Time)
|
||||
|
||||
for _, apiKeyToLastObservedAt := range apiKeyToLastObservedAts {
|
||||
serviceAccountID, ok := apiKeyToLastObservedAt["service_account_id"].(valuer.UUID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
lastObservedAt, ok := apiKeyToLastObservedAt["last_observed_at"].(time.Time)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if lastObservedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := maxLastObservedAtPerServiceAccountID[serviceAccountID]; !ok {
|
||||
maxLastObservedAtPerServiceAccountID[serviceAccountID] = lastObservedAt.UTC()
|
||||
continue
|
||||
}
|
||||
|
||||
if lastObservedAt.UTC().After(maxLastObservedAtPerServiceAccountID[serviceAccountID]) {
|
||||
maxLastObservedAtPerServiceAccountID[serviceAccountID] = lastObservedAt.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
return maxLastObservedAtPerServiceAccountID, nil
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
|
||||
apiKeyToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.apiKeyStore.UpdateLastObservedAtByKey(ctx, apiKeyToLastObservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
apiKey := new(serviceaccounttypes.FactorAPIKey)
|
||||
err := provider.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), apiKey)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
storable, err := provider.apiKeyStore.GetFactorAPIKeyByKey(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiKey = serviceaccounttypes.NewFactorAPIKeyFromStorable(storable)
|
||||
|
||||
err = provider.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), apiKey, provider.config.Lifetime.Max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrGetSetIdentity(ctx context.Context, serviceAccountID valuer.UUID) (*authtypes.Identity, error) {
|
||||
identity := new(authtypes.Identity)
|
||||
err := provider.cache.Get(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
storableServiceAccount, err := provider.apiKeyStore.GetByID(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity = storableServiceAccount.ToIdentity()
|
||||
err = provider.cache.Set(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) listLastObservedAtDesc(ctx context.Context, orgID valuer.UUID) ([]map[string]any, error) {
|
||||
apiKeys, err := provider.apiKeyStore.ListFactorAPIKeyByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keyToLastObservedAt []map[string]any
|
||||
|
||||
for _, key := range apiKeys {
|
||||
keyCachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(key.Key, valuer.MustNewUUID(key.ServiceAccountID)))
|
||||
if ok {
|
||||
keyToLastObservedAt = append(keyToLastObservedAt, map[string]any{
|
||||
"service_account_id": key.ServiceAccountID,
|
||||
"key": key.Key,
|
||||
"last_observed_at": keyCachedLastObservedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sort by descending order of last_observed_at
|
||||
slices.SortFunc(keyToLastObservedAt, func(a, b map[string]any) int {
|
||||
return b["last_observed_at"].(time.Time).Compare(a["last_observed_at"].(time.Time))
|
||||
})
|
||||
|
||||
return keyToLastObservedAt, nil
|
||||
}
|
||||
|
||||
func apiKeyCacheKey(apiKey string) string {
|
||||
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
|
||||
}
|
||||
|
||||
func identityCacheKey(serviceAccountID valuer.UUID) string {
|
||||
return "identity::" + serviceAccountID.String()
|
||||
}
|
||||
|
||||
func lastObservedAtCacheKey(apiKey string, serviceAccountID valuer.UUID) string {
|
||||
return "api_key::" + apiKey + "::" + serviceAccountID.String()
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (
|
||||
return nil, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "claim role mismatch")
|
||||
}
|
||||
|
||||
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role), nil
|
||||
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.UUID{}, authtypes.PrincipalUser, valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role), nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {
|
||||
|
||||
@@ -47,7 +47,7 @@ func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID)
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist", userID)
|
||||
}
|
||||
|
||||
return authtypes.NewIdentity(userID, user.OrgID, user.Email, types.Role(user.Role)), nil
|
||||
return authtypes.NewIdentity(userID, valuer.UUID{}, authtypes.PrincipalUser, user.OrgID, user.Email, types.Role(user.Role)), nil
|
||||
}
|
||||
|
||||
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {
|
||||
|
||||
@@ -25,10 +25,12 @@ var (
|
||||
type AuthNProvider struct{ valuer.String }
|
||||
|
||||
type Identity struct {
|
||||
UserID valuer.UUID `json:"userId"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Role types.Role `json:"role"`
|
||||
UserID valuer.UUID `json:"userId"`
|
||||
ServiceAccountID valuer.UUID `json:"serviceAccountId"`
|
||||
Principal Principal `json:"principal"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Role types.Role `json:"role"`
|
||||
}
|
||||
|
||||
type CallbackIdentity struct {
|
||||
@@ -78,12 +80,14 @@ func NewStateFromString(state string) (State, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role) *Identity {
|
||||
func NewIdentity(userID valuer.UUID, serviceAccountID valuer.UUID, principal Principal, orgID valuer.UUID, email valuer.Email, role types.Role) *Identity {
|
||||
return &Identity{
|
||||
UserID: userID,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
UserID: userID,
|
||||
ServiceAccountID: serviceAccountID,
|
||||
Principal: principal,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +120,12 @@ func (typ *Identity) UnmarshalBinary(data []byte) error {
|
||||
|
||||
func (typ *Identity) ToClaims() Claims {
|
||||
return Claims{
|
||||
UserID: typ.UserID.String(),
|
||||
Email: typ.Email.String(),
|
||||
Role: typ.Role,
|
||||
OrgID: typ.OrgID.String(),
|
||||
UserID: typ.UserID.String(),
|
||||
ServiceAccountID: typ.ServiceAccountID.String(),
|
||||
Principal: typ.Principal.StringValue(),
|
||||
Email: typ.Email.String(),
|
||||
Role: typ.Role,
|
||||
OrgID: typ.OrgID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
|
||||
type claimsKey struct{}
|
||||
type accessTokenKey struct{}
|
||||
type apiKeyKey struct{}
|
||||
|
||||
type Claims struct {
|
||||
UserID string
|
||||
Email string
|
||||
Role types.Role
|
||||
OrgID string
|
||||
UserID string
|
||||
ServiceAccountID string
|
||||
Principal string
|
||||
Email string
|
||||
Role types.Role
|
||||
OrgID string
|
||||
}
|
||||
|
||||
// NewContextWithClaims attaches individual claims to the context.
|
||||
@@ -47,15 +50,38 @@ func AccessTokenFromContext(ctx context.Context) (string, error) {
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func NewContextWithAPIKey(ctx context.Context, apiKey string) context.Context {
|
||||
return context.WithValue(ctx, apiKeyKey{}, apiKey)
|
||||
}
|
||||
|
||||
func APIKeyFromContext(ctx context.Context) (string, error) {
|
||||
apiKey, ok := ctx.Value(apiKeyKey{}).(string)
|
||||
if !ok {
|
||||
return "", errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func (c *Claims) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("user_id", c.UserID),
|
||||
slog.String("service_account_id", c.ServiceAccountID),
|
||||
slog.String("principal", c.Principal),
|
||||
slog.String("email", c.Email),
|
||||
slog.String("role", c.Role.String()),
|
||||
slog.String("org_id", c.OrgID),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Claims) GetIdentityID() string {
|
||||
if c.Principal == PrincipalUser.StringValue() {
|
||||
return c.UserID
|
||||
}
|
||||
|
||||
return c.ServiceAccountID
|
||||
}
|
||||
|
||||
func (c *Claims) IsViewer() error {
|
||||
if slices.Contains([]types.Role{types.RoleViewer, types.RoleEditor, types.RoleAdmin}, c.Role) {
|
||||
return nil
|
||||
|
||||
10
pkg/types/authtypes/principal.go
Normal file
10
pkg/types/authtypes/principal.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package authtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
var (
|
||||
PrincipalUser = Principal{valuer.NewString("user")}
|
||||
PrincipalServiceAccount = Principal{valuer.NewString("service_account")}
|
||||
)
|
||||
|
||||
type Principal struct{ valuer.String }
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
|
||||
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
|
||||
ErrCodeAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
|
||||
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("api_key_invalid_input")
|
||||
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
|
||||
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
|
||||
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
|
||||
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
|
||||
)
|
||||
@@ -184,3 +184,11 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
*key = UpdatableFactorAPIKey(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (key FactorAPIKey) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(key)
|
||||
}
|
||||
|
||||
func (key *FactorAPIKey) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, key)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
@@ -284,3 +285,12 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
|
||||
*sa = UpdatableServiceAccountStatus(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sa *StorableServiceAccount) ToIdentity() *authtypes.Identity {
|
||||
return &authtypes.Identity{
|
||||
ServiceAccountID: sa.ID,
|
||||
Principal: authtypes.PrincipalServiceAccount,
|
||||
OrgID: valuer.MustNewUUID(sa.OrgID),
|
||||
Email: valuer.MustNewEmail(sa.Email),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ type Store interface {
|
||||
// Service Account Factor API Key
|
||||
CreateFactorAPIKey(context.Context, *StorableFactorAPIKey) error
|
||||
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*StorableFactorAPIKey, error)
|
||||
GetFactorAPIKeyByKey(context.Context, string) (*StorableFactorAPIKey, error)
|
||||
ListFactorAPIKeyByOrgID(context.Context, valuer.UUID) ([]*StorableFactorAPIKey, error)
|
||||
ListFactorAPIKey(context.Context, valuer.UUID) ([]*StorableFactorAPIKey, error)
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, *StorableFactorAPIKey) error
|
||||
UpdateLastObservedAtByKey(context.Context, []map[string]any) error
|
||||
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
|
||||
RevokeAllFactorAPIKeys(context.Context, valuer.UUID) error
|
||||
|
||||
|
||||
Reference in New Issue
Block a user