mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-13 08:42:08 +00:00
Compare commits
4 Commits
refactor/r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4ef5ce58 | ||
|
|
5b8d5fbfd3 | ||
|
|
0271be11e6 | ||
|
|
92d220c4d9 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -127,12 +127,15 @@
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
# Dashboard Widget Page
|
||||
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
|
||||
@@ -1768,19 +1768,19 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
last_used:
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
service_account_id:
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
@@ -1788,9 +1788,9 @@ components:
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expires_at
|
||||
- last_used
|
||||
- service_account_id
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKey:
|
||||
properties:
|
||||
@@ -1804,14 +1804,14 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
@@ -1833,13 +1833,16 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgID:
|
||||
orgId:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
@@ -1856,18 +1859,19 @@ components:
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgID
|
||||
- orgId
|
||||
- deletedAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
|
||||
@@ -29,7 +29,6 @@ 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';
|
||||
@@ -384,28 +383,26 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<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>
|
||||
<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>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
last_used: Date;
|
||||
lastObservedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service_account_id: string;
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2173,6 +2173,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
deletedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2188,7 +2193,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgID: string;
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2209,7 +2214,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
.custom-time-picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
|
||||
@@ -16,15 +16,6 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(() => jest.fn()),
|
||||
useSelector: jest.fn(() => ({
|
||||
minTime: 0,
|
||||
maxTime: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
FixedDurationSuggestionOptions,
|
||||
@@ -19,11 +17,9 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -70,8 +66,6 @@ interface CustomTimePickerProps {
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -94,7 +88,6 @@ function CustomTimePicker({
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -123,14 +116,6 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
const durationMs = (maxTime - minTime) / 1e6;
|
||||
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
|
||||
|
||||
const handleZoomOut = useZoomOut({
|
||||
isDisabled: zoomOutDisabled,
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
});
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
@@ -646,23 +631,6 @@ function CustomTimePicker({
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
|
||||
maxTime: NOW_MS * 1e6,
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: (): string => 'relativeTime=45m',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string; offset: string } } => ({
|
||||
timezone: { value: 'UTC', offset: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
onSelect: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
selectedValue: '15m',
|
||||
selectedTime: '15m',
|
||||
onValidCustomDateChange: jest.fn(),
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
items: [
|
||||
{ value: '15m', label: 'Last 15 minutes' },
|
||||
{ value: '1h', label: 'Last 1 hour' },
|
||||
],
|
||||
minTime: (now - 15 * 60 * 1000) * 1e6,
|
||||
maxTime: now * 1e6,
|
||||
};
|
||||
|
||||
describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render zoom out button when showLiveLogs is false', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when showLiveLogs is true', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when isModalTimeSelection is true', () => {
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
showLiveLogs={false}
|
||||
isModalTimeSelection={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleZoomOut when zoom out button is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete activeLogId when zoom out is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
|
||||
it('should disable zoom button when time range is >= 1 month', () => {
|
||||
const now = Date.now();
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
minTime={(now - 31 * MS_PER_DAY) * 1e6}
|
||||
maxTime={now * 1e6}
|
||||
showLiveLogs={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -34,11 +34,6 @@ 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(
|
||||
@@ -69,7 +64,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -110,7 +105,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -149,7 +144,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -104,15 +103,13 @@ describe('LogsPanelComponent', () => {
|
||||
const renderComponent = async (): Promise<void> => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,28 +8,15 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import {
|
||||
getDefaultWidgetData,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { 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';
|
||||
@@ -40,77 +27,25 @@ 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;
|
||||
@@ -123,19 +58,16 @@ 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: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
});
|
||||
handleStageQuery(currentQuery);
|
||||
handleRunQueryFromQueryBuilder();
|
||||
};
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
@@ -164,7 +96,7 @@ function QuerySection({
|
||||
panelType={selectedGraph}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={selectedGraph !== PANEL_TYPES.LIST}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
version={dashboardVersion || 'v3'}
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
@@ -204,7 +136,7 @@ function QuerySection({
|
||||
queryComponents,
|
||||
selectedGraph,
|
||||
filterConfigs,
|
||||
selectedDashboard?.data?.version,
|
||||
dashboardVersion,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
@@ -261,6 +193,11 @@ interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
dashboardName?: string;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
|
||||
@@ -30,6 +30,8 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
selectedDashboard,
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
@@ -75,6 +77,11 @@ 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,7 +8,6 @@ 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';
|
||||
@@ -96,9 +95,7 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
<Provider store={createMockStore()}>
|
||||
<AppContext.Provider value={createMockAppContext() as IAppContext}>
|
||||
<ErrorModalProvider>
|
||||
<DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</ErrorModalProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
|
||||
@@ -310,12 +310,12 @@ describe('Stacking bar in new panel', () => {
|
||||
|
||||
const { container, getByText } = render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="">
|
||||
<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>
|
||||
<DashboardProvider dashboardId="">
|
||||
<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, useParams } from 'react-router-dom';
|
||||
import { generatePath } 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,8 +32,6 @@ 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,
|
||||
@@ -83,6 +81,8 @@ import {
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
function NewWidget({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
@@ -90,11 +90,6 @@ function NewWidget({
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
@@ -139,8 +134,6 @@ function NewWidget({
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
|
||||
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -286,11 +279,10 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
columnWidths: selectedWidget.columnWidths,
|
||||
contextLinks,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnUnits,
|
||||
currentQuery,
|
||||
@@ -313,8 +305,8 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
selectedWidget.columnWidths,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -560,8 +552,7 @@ function NewWidget({
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
onSuccess: () => {
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
@@ -580,7 +571,6 @@ function NewWidget({
|
||||
preWidgets,
|
||||
updateDashboardMutation,
|
||||
widgets,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
safeNavigate,
|
||||
dashboardId,
|
||||
@@ -630,22 +620,25 @@ function NewWidget({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
const isNewPanel = useMemo(() => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId);
|
||||
return isUndefined(selectedWidget);
|
||||
}, [query, widgets]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
logEvent('Panel Edit: Save changes', {
|
||||
panelType: selectedWidget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel: isUndefined(selectWidget),
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [isNewPanel]);
|
||||
|
||||
const isNewTraceLogsAvailable =
|
||||
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
|
||||
@@ -821,6 +814,8 @@ function NewWidget({
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedDashboard={selectedDashboard}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -9,9 +10,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;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ export interface WidgetGraphProps {
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
|
||||
@@ -30,7 +30,6 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -235,7 +234,20 @@ function DateTimeSelection({
|
||||
|
||||
const updateLocalStorageForRoutes = useCallback(
|
||||
(value: Time | string): void => {
|
||||
persistTimeDurationForRoute(location.pathname, String(value));
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
if (preRoutes !== null) {
|
||||
const preRoutesObject = JSON.parse(preRoutes);
|
||||
|
||||
const preRoute = {
|
||||
...preRoutesObject,
|
||||
};
|
||||
preRoute[location.pathname] = value;
|
||||
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
@@ -726,7 +738,6 @@ function DateTimeSelection({
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
minTime={minTimeForDateTimePicker}
|
||||
maxTime={maxTimeForDateTimePicker}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { useZoomOut } from '../useZoomOut';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryToString = jest.fn(() => '');
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: <T>(selector: (state: MockAppState) => T): T => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
|
||||
maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: typeof mockUrlQueryToString;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: mockUrlQueryToString,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetNextZoomOutRange = jest.fn();
|
||||
jest.mock('lib/zoomOutUtils', () => ({
|
||||
getNextZoomOutRange: (
|
||||
...args: unknown[]
|
||||
): ReturnType<typeof mockGetNextZoomOutRange> =>
|
||||
mockGetNextZoomOutRange(...args),
|
||||
}));
|
||||
|
||||
describe('useZoomOut', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQueryToString.mockReturnValue('relativeTime=45m');
|
||||
});
|
||||
|
||||
it('should do nothing when isDisabled is true', () => {
|
||||
const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when getNextZoomOutRange returns null', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispatch preset and update URL when result has preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom range and update URL when result has no preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000000, 2000000],
|
||||
preset: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(
|
||||
QueryParams.startTime,
|
||||
'1000000',
|
||||
);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete urlParamsToDelete when provided', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useZoomOut({
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
|
||||
jest.mock('hooks/dashboard/useVariablesFromUrl');
|
||||
|
||||
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
|
||||
typeof useDashboardVariablesFromLocalStorage
|
||||
>;
|
||||
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
|
||||
typeof useVariablesFromUrl
|
||||
>;
|
||||
|
||||
const makeVariable = (
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable => ({
|
||||
id: 'existing-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
selectedValue: 'prod',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDashboard = (
|
||||
variables: Record<string, IDashboardVariable>,
|
||||
): Dashboard => ({
|
||||
id: 'dash-1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test',
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
const setupHook = (
|
||||
currentDashboard: Record<string, any> = {},
|
||||
urlVariables: Record<string, any> = {},
|
||||
): ReturnType<typeof useTransformDashboardVariables> => {
|
||||
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
});
|
||||
mockUseVariablesFromUrl.mockReturnValue({
|
||||
getUrlVariables: () => urlVariables,
|
||||
setUrlVariables: jest.fn(),
|
||||
updateUrlVariable: jest.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
|
||||
return result.current;
|
||||
};
|
||||
|
||||
describe('useTransformDashboardVariables', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('order assignment', () => {
|
||||
it('assigns order starting from 0 to variables that have none', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
|
||||
it('preserves existing order values', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ID assignment', () => {
|
||||
it('assigns a UUID to variables that have no id', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const variable = makeVariable({ name: 'v1' });
|
||||
(variable as any).id = undefined;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing IDs', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEXTBOX backward compatibility', () => {
|
||||
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'hello',
|
||||
defaultValue: undefined,
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'old',
|
||||
defaultValue: 'keep',
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage merge', () => {
|
||||
it('applies localStorage selectedValue over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: undefined, allSelected: true },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
allSelected: false,
|
||||
showALLOption: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL variable override', () => {
|
||||
it('sets allSelected=true when URL value is __ALL__', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: 'prod', allSelected: false } },
|
||||
{ env: '__ALL__' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: false,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ env: ['prod', 'dev'] },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ 'var-uuid': 'fallback' },
|
||||
);
|
||||
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
|
||||
delete variable.name;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns data unchanged when there are no variables', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables).toEqual({});
|
||||
});
|
||||
|
||||
it('does not mutate the original dashboard', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
|
||||
[id: string]: LocalStoreDashboardVariables;
|
||||
}
|
||||
|
||||
interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
export interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
currentDashboard: LocalStoreDashboardVariables;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
|
||||
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import {
|
||||
useDashboardVariablesFromLocalStorage,
|
||||
UseDashboardVariablesFromLocalStorageReturn,
|
||||
} from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import useVariablesFromUrl, {
|
||||
UseVariablesFromUrlReturn,
|
||||
} from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
export function useTransformDashboardVariables(
|
||||
dashboardId: string,
|
||||
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
|
||||
UseDashboardVariablesFromLocalStorageReturn & {
|
||||
transformDashboardVariables: (data: Dashboard) => Dashboard;
|
||||
} {
|
||||
const {
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const mergeDBWithLocalStorage = (
|
||||
data: Dashboard,
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
// respect the url variable if it is set, override the others
|
||||
if (!isEmpty(urlVariable)) {
|
||||
if (urlVariable === ALL_SELECTED_VALUE) {
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
allSelected: true,
|
||||
};
|
||||
} else {
|
||||
// Normalize URL value to match variable's multiSelect configuration
|
||||
const normalizedValue = normalizeUrlValueForVariable(
|
||||
urlVariable,
|
||||
variableData,
|
||||
);
|
||||
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
selectedValue: normalizedValue,
|
||||
// Only set allSelected to false if showALLOption is available
|
||||
...(updatedVariable?.showALLOption && { allSelected: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updatedVariables[variable] = updatedVariable;
|
||||
});
|
||||
updatedData.data.variables = updatedVariables;
|
||||
}
|
||||
return updatedData;
|
||||
};
|
||||
|
||||
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
||||
if (data && data.data && data.data.variables) {
|
||||
const clonedDashboardData = mergeDBWithLocalStorage(
|
||||
JSON.parse(JSON.stringify(data)),
|
||||
currentDashboard,
|
||||
);
|
||||
const { variables } = clonedDashboardData.data;
|
||||
const existingOrders: Set<number> = new Set();
|
||||
|
||||
for (const key in variables) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (variables.hasOwnProperty(key)) {
|
||||
const variable: IDashboardVariable = variables[key];
|
||||
|
||||
// Check if 'order' property doesn't exist or is undefined
|
||||
if (variable.order === undefined) {
|
||||
// Find a unique order starting from 0
|
||||
let order = 0;
|
||||
while (existingOrders.has(order)) {
|
||||
order += 1;
|
||||
}
|
||||
|
||||
variable.order = order;
|
||||
existingOrders.add(order);
|
||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||
variable.defaultValue = variable.textboxValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.id === undefined) {
|
||||
variable.id = generateUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clonedDashboardData;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
transformDashboardVariables,
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
|
||||
| IDashboardVariable['selectedValue'];
|
||||
}
|
||||
|
||||
interface UseVariablesFromUrlReturn {
|
||||
export interface UseVariablesFromUrlReturn {
|
||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||
updateUrlVariable: (
|
||||
|
||||
@@ -107,7 +107,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryRangeMutation,
|
||||
dashboardVariables,
|
||||
dashboardDynamicVariables,
|
||||
selectedDashboard?.data.version,
|
||||
widget,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
|
||||
export interface UseZoomOutOptions {
|
||||
/** When true, the zoom out handler does nothing (e.g. when live logs are enabled) */
|
||||
isDisabled?: boolean;
|
||||
/** URL params to delete when zooming out (e.g. [QueryParams.activeLogId] for logs) */
|
||||
urlParamsToDelete?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for zoom-out functionality in explorers (logs, traces, etc.).
|
||||
* Computes the next time range using the zoom-out ladder, updates Redux global time,
|
||||
* and navigates with the new URL params.
|
||||
*/
|
||||
const EMPTY_PARAMS: string[] = [];
|
||||
|
||||
export function useZoomOut(options: UseZoomOutOptions = {}): () => void {
|
||||
const { isDisabled = false, urlParamsToDelete = EMPTY_PARAMS } = options;
|
||||
const urlParamsToDeleteRef = useRef(urlParamsToDelete);
|
||||
urlParamsToDeleteRef.current = urlParamsToDelete;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback((): void => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
const minMs = Math.floor((minTime ?? 0) / 1e6);
|
||||
const maxMs = Math.floor((maxTime ?? 0) / 1e6);
|
||||
const result = getNextZoomOutRange(minMs, maxMs);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [newStartMs, newEndMs] = result.range;
|
||||
const { preset } = result;
|
||||
|
||||
if (preset) {
|
||||
dispatch(UpdateTimeInterval(preset));
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
urlQuery.set(QueryParams.relativeTime, preset);
|
||||
persistTimeDurationForRoute(location.pathname, preset);
|
||||
} else {
|
||||
dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
|
||||
urlQuery.set(QueryParams.startTime, String(newStartMs));
|
||||
urlQuery.set(QueryParams.endTime, String(newEndMs));
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
}
|
||||
for (const param of urlParamsToDeleteRef.current) {
|
||||
urlQuery.delete(param);
|
||||
}
|
||||
safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}, [
|
||||
dispatch,
|
||||
isDisabled,
|
||||
location.pathname,
|
||||
maxTime,
|
||||
minTime,
|
||||
safeNavigate,
|
||||
urlQuery,
|
||||
]);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import {
|
||||
getNextDurationInLadder,
|
||||
getNextZoomOutRange,
|
||||
isZoomOutDisabled,
|
||||
ZoomOutResult,
|
||||
} from '../zoomOutUtils';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
// Fixed "now" for deterministic tests: 2024-01-15 12:00:00 UTC
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
describe('zoomOutUtils', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getNextDurationInLadder', () => {
|
||||
it('should use 3x zoom out below 15m until reaching 15m', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_MIN)).toBe(3 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_MIN)).toBe(6 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_MIN)).toBe(9 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(4 * MS_PER_MIN)).toBe(12 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // cap at 15m
|
||||
expect(getNextDurationInLadder(6 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // 18m capped
|
||||
});
|
||||
|
||||
it('should return next step for each ladder rung from 15m onward', () => {
|
||||
expect(getNextDurationInLadder(10 * MS_PER_MIN)).toBe(15 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(15 * MS_PER_MIN)).toBe(45 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(45 * MS_PER_MIN)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(7 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(21 * MS_PER_HOUR)).toBe(1 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_DAY)).toBe(2 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_DAY)).toBe(3 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_DAY)).toBe(1 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_WEEK)).toBe(2 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_WEEK)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return MAX when at or past 1 month (no wrap)', () => {
|
||||
expect(getNextDurationInLadder(30 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(31 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return next step for duration between ladder rungs', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_HOUR)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(12 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextZoomOutRange', () => {
|
||||
it('should return null when duration is zero or negative', () => {
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS)).toBeNull();
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS - 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return center-anchored range and preset=null when new end does not exceed now (Phase 1)', () => {
|
||||
// 15m range centered well before now so zoom to 45m keeps end <= now
|
||||
// Center at now-30m: end = center + 22.5m = now - 7.5m <= now
|
||||
const centerMs = NOW_MS - 30 * MS_PER_MIN;
|
||||
const start15m = centerMs - 7.5 * MS_PER_MIN;
|
||||
const end15m = centerMs + 7.5 * MS_PER_MIN;
|
||||
const result = getNextZoomOutRange(start15m, end15m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBeNull(); // Phase 1: preserve center-anchored range, avoid GetMinMax "last X from now"
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(45 * MS_PER_MIN);
|
||||
const newCenter = (newStart + newEnd) / 2;
|
||||
expect(Math.abs(newCenter - centerMs)).toBeLessThan(2000);
|
||||
expect(newEnd).toBeLessThanOrEqual(NOW_MS + 1000);
|
||||
});
|
||||
|
||||
it('should return end-anchored range when new end would exceed now (Phase 2)', () => {
|
||||
// 22hr range ending at now - zoom to 1d (24hr) would push end past now
|
||||
// Next ladder step from 22hr is 1d
|
||||
const start22h = NOW_MS - 22 * MS_PER_HOUR;
|
||||
const end22h = NOW_MS;
|
||||
const result = getNextZoomOutRange(start22h, end22h) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('1d');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd).toBe(NOW_MS); // End anchored at now
|
||||
expect(newStart).toBe(NOW_MS - 1 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return correct preset for each ladder step', () => {
|
||||
const presets: [number, number, string][] = [
|
||||
[15 * MS_PER_MIN, 0, '45m'],
|
||||
[45 * MS_PER_MIN, 0, '2h'],
|
||||
[2 * MS_PER_HOUR, 0, '7h'],
|
||||
[7 * MS_PER_HOUR, 0, '21h'],
|
||||
[21 * MS_PER_HOUR, 0, '1d'],
|
||||
[1 * MS_PER_DAY, 0, '2d'],
|
||||
[2 * MS_PER_DAY, 0, '3d'],
|
||||
[3 * MS_PER_DAY, 0, '1w'],
|
||||
[1 * MS_PER_WEEK, 0, '2w'],
|
||||
[2 * MS_PER_WEEK, 0, '1month'],
|
||||
];
|
||||
|
||||
presets.forEach(([durationMs, offset, expectedPreset]) => {
|
||||
const end = NOW_MS - offset;
|
||||
const start = end - durationMs;
|
||||
const result = getNextZoomOutRange(start, end);
|
||||
expect(result?.preset).toBe(expectedPreset);
|
||||
});
|
||||
});
|
||||
|
||||
it('isZoomOutDisabled returns true when duration >= 1 month', () => {
|
||||
expect(isZoomOutDisabled(30 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(31 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(29 * MS_PER_DAY)).toBe(false);
|
||||
expect(isZoomOutDisabled(15 * MS_PER_MIN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null when at 1 month (no zoom out beyond max)', () => {
|
||||
const start1m = NOW_MS - 30 * MS_PER_DAY;
|
||||
const end1m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start1m, end1m);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should zoom out 3x from 5m range to 15m then continue with ladder', () => {
|
||||
// 5m range ending at now → 3x = 15m
|
||||
const start5m = NOW_MS - 5 * MS_PER_MIN;
|
||||
const end5m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start5m, end5m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('15m');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(15 * MS_PER_MIN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Custom Time Picker zoom-out ladder:
|
||||
* - Until 1 day: 15m → 45m → 2hr → 7hr → 21hr
|
||||
* - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
|
||||
* - At 1 month: zoom out is disabled (max range)
|
||||
*/
|
||||
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
const ZOOM_OUT_LADDER_MS: number[] = [
|
||||
15 * MS_PER_MIN, // 15m
|
||||
45 * MS_PER_MIN, // 45m
|
||||
2 * MS_PER_HOUR, // 2hr
|
||||
7 * MS_PER_HOUR, // 7hr
|
||||
21 * MS_PER_HOUR, // 21hr
|
||||
1 * MS_PER_DAY, // 1d
|
||||
2 * MS_PER_DAY, // 2d
|
||||
3 * MS_PER_DAY, // 3d
|
||||
1 * MS_PER_WEEK, // 1w
|
||||
2 * MS_PER_WEEK, // 2w
|
||||
30 * MS_PER_DAY, // 1m
|
||||
];
|
||||
|
||||
const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
|
||||
const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
|
||||
const MIN_LADDER_DURATION_MS = ZOOM_OUT_LADDER_MS[0]; // 15m - below this we use 3x
|
||||
|
||||
export const MAX_ZOOM_OUT_DURATION_MS = MAX_DURATION;
|
||||
|
||||
/** Returns true when zoom out should be disabled (range at or beyond 1 month) */
|
||||
export function isZoomOutDisabled(durationMs: number): boolean {
|
||||
return durationMs >= MAX_ZOOM_OUT_DURATION_MS;
|
||||
}
|
||||
|
||||
/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
|
||||
const PRESET_FOR_DURATION_MS: Record<number, Time | CustomTimeType> = {
|
||||
[15 * MS_PER_MIN]: '15m',
|
||||
[45 * MS_PER_MIN]: '45m',
|
||||
[2 * MS_PER_HOUR]: '2h',
|
||||
[7 * MS_PER_HOUR]: '7h',
|
||||
[21 * MS_PER_HOUR]: '21h',
|
||||
[1 * MS_PER_DAY]: '1d',
|
||||
[2 * MS_PER_DAY]: '2d',
|
||||
[3 * MS_PER_DAY]: '3d',
|
||||
[1 * MS_PER_WEEK]: '1w',
|
||||
[2 * MS_PER_WEEK]: '2w',
|
||||
[30 * MS_PER_DAY]: '1month',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the next duration in the zoom-out ladder for the given current duration.
|
||||
* Below 15m: zoom out 3x until we reach 15m, then continue with the ladder.
|
||||
* If at or past 1 month, returns MAX_DURATION (no zoom out - button is disabled).
|
||||
*/
|
||||
export function getNextDurationInLadder(durationMs: number): number {
|
||||
if (durationMs >= MAX_DURATION) {
|
||||
return MAX_DURATION; // No zoom out beyond 1 month
|
||||
}
|
||||
|
||||
// Below 15m: zoom out 3x until we reach 15m
|
||||
if (durationMs < MIN_LADDER_DURATION_MS) {
|
||||
const next = durationMs * 3;
|
||||
return Math.min(next, MIN_LADDER_DURATION_MS);
|
||||
}
|
||||
|
||||
// At or above 15m: use the fixed ladder
|
||||
for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
|
||||
if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
|
||||
return ZOOM_OUT_LADDER_MS[i];
|
||||
}
|
||||
}
|
||||
|
||||
return MAX_DURATION;
|
||||
}
|
||||
|
||||
export interface ZoomOutResult {
|
||||
range: [number, number];
|
||||
/** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
|
||||
preset: Time | CustomTimeType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next zoomed-out time range.
|
||||
* Phase 1 (center-anchored): While new end <= now, expand from center.
|
||||
* Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
|
||||
*
|
||||
* @returns ZoomOutResult with range and preset (or null if no change)
|
||||
*/
|
||||
export function getNextZoomOutRange(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): ZoomOutResult | null {
|
||||
const nowMs = Date.now();
|
||||
const durationMs = endMs - startMs;
|
||||
|
||||
if (durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newDurationMs = getNextDurationInLadder(durationMs);
|
||||
|
||||
// No zoom out when already at max (1 month)
|
||||
if (newDurationMs <= durationMs) {
|
||||
return null;
|
||||
}
|
||||
const centerMs = startMs + durationMs / 2;
|
||||
const computedEndMs = centerMs + newDurationMs / 2;
|
||||
|
||||
let newStartMs: number;
|
||||
let newEndMs: number;
|
||||
|
||||
const isPhase1 = computedEndMs <= nowMs;
|
||||
if (isPhase1) {
|
||||
// Phase 1: center-anchored (historical range not ending at now)
|
||||
newStartMs = centerMs - newDurationMs / 2;
|
||||
newEndMs = computedEndMs;
|
||||
} else {
|
||||
// Phase 2: end-anchored at now
|
||||
newStartMs = nowMs - newDurationMs;
|
||||
newEndMs = nowMs;
|
||||
}
|
||||
|
||||
// Phase 2 only: use preset so GetMinMax produces "last X from now".
|
||||
// Phase 1: preset=null so the center-anchored range is preserved (GetMinMax would discard it).
|
||||
const preset = isPhase1 ? null : PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
|
||||
|
||||
return {
|
||||
range: [Math.round(newStartMs), Math.round(newEndMs)],
|
||||
preset,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
export default DashboardPage;
|
||||
function DashboardPageWithProvider(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
return (
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardPage />
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageWithProvider;
|
||||
|
||||
@@ -1,50 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { generatePath, useLocation, useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import { generatePath, 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 { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
function DashboardWidget(): JSX.Element | null {
|
||||
const { search } = useLocation();
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
const { dashboardId } = useParams<{
|
||||
dashboardId: string;
|
||||
}>();
|
||||
const [widgetId] = useQueryState('widgetId');
|
||||
const [graphType] = useQueryState(
|
||||
'graphType',
|
||||
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)),
|
||||
);
|
||||
|
||||
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(() => {
|
||||
const params = new URLSearchParams(search);
|
||||
const graphType = params.get('graphType') as PANEL_TYPES | null;
|
||||
|
||||
if (graphType === null) {
|
||||
if (!graphType || !widgetId) {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
} else {
|
||||
setSelectedGraph(graphType);
|
||||
} else if (!dashboardId) {
|
||||
safeNavigate(ROUTES.HOME);
|
||||
}
|
||||
}, [dashboardId, safeNavigate, search]);
|
||||
}, [graphType, widgetId, dashboardId, safeNavigate]);
|
||||
|
||||
if (selectedGraph === undefined || dashboardResponse.isLoading) {
|
||||
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 [selectedDashboard, setSelectedDashboard] = useState<
|
||||
Dashboard | undefined
|
||||
>(undefined);
|
||||
|
||||
const { transformDashboardVariables } = useTransformDashboardVariables(
|
||||
dashboardId,
|
||||
);
|
||||
|
||||
const {
|
||||
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) => {
|
||||
const updatedDashboardData = transformDashboardVariables(response.data);
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingDashboardResponse) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (dashboardResponse.isError) {
|
||||
if (isErrorDashboardResponse) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography>{SOMETHING_WENT_WRONG}</Typography>
|
||||
@@ -54,16 +102,11 @@ function DashboardWidget(): JSX.Element | null {
|
||||
|
||||
return (
|
||||
<NewWidget
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
dashboardId={dashboardId}
|
||||
selectedGraph={graphType}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
selectedDashboard={selectedDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DashboardWidgetPageParams {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export default DashboardWidget;
|
||||
|
||||
@@ -8,7 +8,6 @@ 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', () => ({
|
||||
@@ -19,11 +18,6 @@ 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();
|
||||
@@ -47,9 +41,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=asgard&order=stones&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -71,9 +63,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=createdAt&order=descend&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -92,9 +82,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -135,9 +123,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -164,9 +150,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -196,9 +180,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -14,26 +14,21 @@ 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';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import { defaultTo, isEmpty } from 'lodash-es';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import isUndefined from 'lodash-es/isUndefined';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -41,10 +36,9 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
@@ -83,14 +77,11 @@ export const DashboardContext = createContext<IDashboardContext>({
|
||||
setColumnWidths: () => {},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function DashboardProvider({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element {
|
||||
dashboardId,
|
||||
}: PropsWithChildren<{ dashboardId: string }>): JSX.Element {
|
||||
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
|
||||
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
|
||||
@@ -100,11 +91,6 @@ export function DashboardProvider({
|
||||
setDashboardQueryRangeCalled,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const isDashboardPage = useRouteMatch<Props>({
|
||||
path: ROUTES.DASHBOARD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
@@ -115,11 +101,6 @@ 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<
|
||||
@@ -128,11 +109,6 @@ 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);
|
||||
@@ -157,9 +133,10 @@ export function DashboardProvider({
|
||||
const {
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
transformDashboardVariables,
|
||||
} = useTransformDashboardVariables(dashboardId);
|
||||
|
||||
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
|
||||
const modalRef = useRef<any>(null);
|
||||
@@ -171,108 +148,14 @@ export function DashboardProvider({
|
||||
|
||||
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
|
||||
|
||||
const mergeDBWithLocalStorage = (
|
||||
data: Dashboard,
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
// respect the url variable if it is set, override the others
|
||||
if (!isEmpty(urlVariable)) {
|
||||
if (urlVariable === ALL_SELECTED_VALUE) {
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
allSelected: true,
|
||||
};
|
||||
} else {
|
||||
// Normalize URL value to match variable's multiSelect configuration
|
||||
const normalizedValue = normalizeUrlValueForVariable(
|
||||
urlVariable,
|
||||
variableData,
|
||||
);
|
||||
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
selectedValue: normalizedValue,
|
||||
// Only set allSelected to false if showALLOption is available
|
||||
...(updatedVariable?.showALLOption && { allSelected: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updatedVariables[variable] = updatedVariable;
|
||||
});
|
||||
updatedData.data.variables = updatedVariables;
|
||||
}
|
||||
return updatedData;
|
||||
};
|
||||
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
||||
if (data && data.data && data.data.variables) {
|
||||
const clonedDashboardData = mergeDBWithLocalStorage(
|
||||
JSON.parse(JSON.stringify(data)),
|
||||
currentDashboard,
|
||||
);
|
||||
const { variables } = clonedDashboardData.data;
|
||||
const existingOrders: Set<number> = new Set();
|
||||
|
||||
for (const key in variables) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (variables.hasOwnProperty(key)) {
|
||||
const variable: IDashboardVariable = variables[key];
|
||||
|
||||
// Check if 'order' property doesn't exist or is undefined
|
||||
if (variable.order === undefined) {
|
||||
// Find a unique order starting from 0
|
||||
let order = 0;
|
||||
while (existingOrders.has(order)) {
|
||||
order += 1;
|
||||
}
|
||||
|
||||
variable.order = order;
|
||||
existingOrders.add(order);
|
||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||
variable.defaultValue = variable.textboxValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.id === undefined) {
|
||||
variable.id = generateUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clonedDashboardData;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
const dashboardResponse = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
isDashboardPage?.params,
|
||||
dashboardId,
|
||||
globalTime.isAutoRefreshDisabled,
|
||||
],
|
||||
{
|
||||
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
||||
enabled: !!dashboardId && isLoggedIn,
|
||||
queryFn: async () => {
|
||||
setIsDashboardFetching(true);
|
||||
try {
|
||||
@@ -392,11 +275,7 @@ export function DashboardProvider({
|
||||
|
||||
useEffect(() => {
|
||||
// make the call on tab visibility only if the user is on dashboard / widget page
|
||||
if (
|
||||
isVisible &&
|
||||
updatedTimeRef.current &&
|
||||
(!!isDashboardPage || !!isDashboardWidgetPage)
|
||||
) {
|
||||
if (isVisible && updatedTimeRef.current && !!dashboardId) {
|
||||
dashboardResponse.refetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -2,11 +2,10 @@ 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, screen, waitFor } from '@testing-library/react';
|
||||
import { render, RenderResult, 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';
|
||||
|
||||
@@ -19,30 +18,28 @@ 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: (): any => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock only the essential dependencies for Dashboard provider
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): any => ({
|
||||
useAppContext: (): {
|
||||
isLoggedIn: boolean;
|
||||
user: { email: string; role: string };
|
||||
} => ({
|
||||
isLoggedIn: true,
|
||||
user: { email: 'test@example.com', role: 'ADMIN' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): any => ({ showErrorModal: jest.fn() }),
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -60,11 +57,10 @@ 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">{dashboardId}</div>
|
||||
<div data-testid="dashboard-id">{selectedDashboard?.id}</div>
|
||||
<div data-testid="query-status">{dashboardResponse.status}</div>
|
||||
<div data-testid="is-loading">{dashboardResponse.isLoading.toString()}</div>
|
||||
<div data-testid="is-fetching">
|
||||
@@ -94,27 +90,15 @@ function createTestQueryClient(): QueryClient {
|
||||
|
||||
// Helper to render with dashboard provider
|
||||
function renderWithDashboardProvider(
|
||||
initialRoute = '/dashboard/test-dashboard-id',
|
||||
routeMatchParams?: { dashboardId: string } | null,
|
||||
): any {
|
||||
dashboardId = 'test-dashboard-id',
|
||||
): RenderResult {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
// Mock the route match
|
||||
mockUseRouteMatch.mockReturnValue(
|
||||
routeMatchParams
|
||||
? {
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${routeMatchParams.dashboardId}`,
|
||||
isExact: true,
|
||||
params: routeMatchParams,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const initialRoute = dashboardId ? `/dashboard/${dashboardId}` : '/dashboard';
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -188,7 +172,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(`/dashboard/${dashboardId}`, { dashboardId });
|
||||
renderWithDashboardProvider(dashboardId);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
|
||||
@@ -203,30 +187,17 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
const newDashboardId = 'new-dashboard-id';
|
||||
|
||||
// First render with initial dashboard ID
|
||||
const { rerender } = renderWithDashboardProvider(
|
||||
`/dashboard/${initialDashboardId}`,
|
||||
{
|
||||
dashboardId: initialDashboardId,
|
||||
},
|
||||
);
|
||||
const { rerender } = renderWithDashboardProvider(initialDashboardId);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: initialDashboardId });
|
||||
});
|
||||
|
||||
// Change route params to simulate navigation
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${newDashboardId}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: newDashboardId },
|
||||
});
|
||||
|
||||
// Rerender with new route
|
||||
// Rerender with new dashboard ID prop
|
||||
rerender(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${newDashboardId}`]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={newDashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -241,50 +212,24 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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);
|
||||
it('should not fetch when no dashboardId is provided', () => {
|
||||
renderWithDashboardProvider('');
|
||||
|
||||
// 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 route params', async () => {
|
||||
it('should create separate cache entries for different dashboardIds', 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>
|
||||
<DashboardProvider dashboardId={dashboardId1}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -295,18 +240,10 @@ 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>
|
||||
<DashboardProvider dashboardId={dashboardId2}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -325,13 +262,11 @@ 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
|
||||
]);
|
||||
@@ -348,17 +283,10 @@ 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>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -375,7 +303,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
.find(
|
||||
(query) =>
|
||||
query.queryKey[0] === REACT_QUERY_KEY.DASHBOARD_BY_ID &&
|
||||
query.queryKey[3] === false,
|
||||
query.queryKey[2] === false,
|
||||
);
|
||||
expect(dashboardQuery).toBeDefined();
|
||||
expect((dashboardQuery as { cacheTime: number }).cacheTime).toBe(
|
||||
@@ -437,9 +365,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
// Empty URL variables - tests initialization flow
|
||||
mockGetUrlVariables.mockReturnValue({});
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -493,9 +419,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['db', 'cache']);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -555,9 +479,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
|
||||
mockGetUrlVariables.mockReturnValue(urlVariables);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -593,9 +515,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['api']);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify normalization was called with the specific values and variable configs
|
||||
@@ -662,9 +582,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -706,9 +624,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -751,9 +667,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -795,9 +709,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
/**
|
||||
* Updates the stored time duration for a route in localStorage.
|
||||
* Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
|
||||
*
|
||||
* @param pathname - The route path (e.g. /infrastructure-monitoring/hosts)
|
||||
* @param value - The time value to store (preset string like '1w' or JSON string for custom range)
|
||||
*/
|
||||
export function persistTimeDurationForRoute(
|
||||
pathname: string,
|
||||
value: string,
|
||||
): void {
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
let preRoutesObject: Record<string, string> = {};
|
||||
try {
|
||||
preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
|
||||
} catch {
|
||||
preRoutesObject = {};
|
||||
}
|
||||
const preRoute = { ...preRoutesObject, [pathname]: value };
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
@@ -111,7 +111,12 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -147,7 +152,12 @@ func (handler *handler) UpdateStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.UpdateStatus(req.Status)
|
||||
err = serviceAccount.UpdateStatus(req.Status)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateStatus(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -290,7 +300,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
factorAPIKey.Update(req.Name, req.ExpiresAt)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, serviceAccount.ID, factorAPIKey)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -60,6 +61,24 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
existingServiceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, serviceAccount.OrgID, serviceAccount.Name)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingServiceAccount != nil {
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
@@ -171,26 +190,28 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
|
||||
}
|
||||
|
||||
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, input.ID)
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Status == serviceAccount.Status {
|
||||
return nil
|
||||
}
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch input.Status {
|
||||
case serviceaccounttypes.StatusActive:
|
||||
err := module.activateServiceAccount(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case serviceaccounttypes.StatusDisabled:
|
||||
err := module.disableServiceAccount(ctx, orgID, input)
|
||||
// update the status but do not delete the role mappings as we will use them for audits
|
||||
err = module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -276,8 +297,13 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
return module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -307,45 +333,3 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) disableServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status but do not delete the role mappings as we will reuse them on activation.
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) activateServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Grant(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,6 +48,25 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Where("status = ?", serviceaccounttypes.StatusActive).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with name: %s doesn't exist in org: %s", name, orgID.String())
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
@@ -188,7 +207,7 @@ func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceacc
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountFactorAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -206,7 +225,7 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccounFactorAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
|
||||
@@ -15,6 +15,9 @@ type Module interface {
|
||||
// Gets a service account by id.
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets or creates a service account by name
|
||||
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets a service account by id without fetching roles.
|
||||
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
@@ -40,7 +43,7 @@ type Module interface {
|
||||
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Updates an existing API key for a service account
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
|
||||
// Revokes an existing API key for a service account
|
||||
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
@@ -11,20 +11,22 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeServiceAccountFactorAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
|
||||
ErrCodeServiceAccountFactorAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
|
||||
ErrCodeServiceAccounFactorAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
|
||||
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")
|
||||
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
|
||||
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
|
||||
)
|
||||
|
||||
type StorableFactorAPIKey struct {
|
||||
bun.BaseModel `bun:"table:factor_api_key"`
|
||||
bun.BaseModel `bun:"table:factor_api_key,alias:factor_api_key"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `bun:"name"`
|
||||
Key string `bun:"key"`
|
||||
ExpiresAt uint64 `bun:"expires_at"`
|
||||
LastUsed time.Time `bun:"last_used"`
|
||||
LastObservedAt time.Time `bun:"last_observed_at"`
|
||||
ServiceAccountID string `bun:"service_account_id"`
|
||||
}
|
||||
|
||||
@@ -33,9 +35,9 @@ type FactorAPIKey struct {
|
||||
types.TimeAuditable
|
||||
Name string `json:"name" requrired:"true"`
|
||||
Key string `json:"key" required:"true"`
|
||||
ExpiresAt uint64 `json:"expires_at" required:"true"`
|
||||
LastUsed time.Time `json:"last_used" required:"true"`
|
||||
ServiceAccountID valuer.UUID `json:"service_account_id" required:"true"`
|
||||
ExpiresAt uint64 `json:"expiresAt" required:"true"`
|
||||
LastObservedAt time.Time `json:"lastObservedAt" required:"true"`
|
||||
ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"`
|
||||
}
|
||||
|
||||
type GettableFactorAPIKeyWithKey struct {
|
||||
@@ -47,19 +49,19 @@ type GettableFactorAPIKey struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `json:"name" requrired:"true"`
|
||||
ExpiresAt uint64 `json:"expires_at" required:"true"`
|
||||
LastUsed time.Time `json:"last_used" required:"true"`
|
||||
ServiceAccountID valuer.UUID `json:"service_account_id" required:"true"`
|
||||
ExpiresAt uint64 `json:"expiresAt" required:"true"`
|
||||
LastObservedAt time.Time `json:"lastObservedAt" required:"true"`
|
||||
ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"`
|
||||
}
|
||||
|
||||
type PostableFactorAPIKey struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
ExpiresAt uint64 `json:"expires_at" required:"true"`
|
||||
ExpiresAt uint64 `json:"expiresAt" required:"true"`
|
||||
}
|
||||
|
||||
type UpdatableFactorAPIKey struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
ExpiresAt uint64 `json:"expires_at" required:"true"`
|
||||
ExpiresAt uint64 `json:"expiresAt" required:"true"`
|
||||
}
|
||||
|
||||
func NewFactorAPIKeyFromStorable(storable *StorableFactorAPIKey) *FactorAPIKey {
|
||||
@@ -69,7 +71,7 @@ func NewFactorAPIKeyFromStorable(storable *StorableFactorAPIKey) *FactorAPIKey {
|
||||
Name: storable.Name,
|
||||
Key: storable.Key,
|
||||
ExpiresAt: storable.ExpiresAt,
|
||||
LastUsed: storable.LastUsed,
|
||||
LastObservedAt: storable.LastObservedAt,
|
||||
ServiceAccountID: valuer.MustNewUUID(storable.ServiceAccountID),
|
||||
}
|
||||
}
|
||||
@@ -91,7 +93,7 @@ func NewStorableFactorAPIKey(factorAPIKey *FactorAPIKey) *StorableFactorAPIKey {
|
||||
Name: factorAPIKey.Name,
|
||||
Key: factorAPIKey.Key,
|
||||
ExpiresAt: factorAPIKey.ExpiresAt,
|
||||
LastUsed: factorAPIKey.LastUsed,
|
||||
LastObservedAt: factorAPIKey.LastObservedAt,
|
||||
ServiceAccountID: factorAPIKey.ServiceAccountID.String(),
|
||||
}
|
||||
}
|
||||
@@ -105,7 +107,7 @@ func NewGettableFactorAPIKeys(keys []*FactorAPIKey) []*GettableFactorAPIKey {
|
||||
TimeAuditable: key.TimeAuditable,
|
||||
Name: key.Name,
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
LastUsed: key.LastUsed,
|
||||
LastObservedAt: key.LastObservedAt,
|
||||
ServiceAccountID: key.ServiceAccountID,
|
||||
}
|
||||
}
|
||||
@@ -128,6 +130,29 @@ func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) {
|
||||
apiKey.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (apiKey *FactorAPIKey) IsExpired() error {
|
||||
if apiKey.ExpiresAt == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(time.Unix(int64(apiKey.ExpiresAt), 0)) {
|
||||
return errors.New(errors.TypeUnauthenticated, ErrCodeAPIKeyExpired, "api key has been expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (apiKey *FactorAPIKey) UpdateLastObservedAt(lastObservedAt time.Time) error {
|
||||
if lastObservedAt.Before(apiKey.LastObservedAt) {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyOlderLastObservedAt, "last observed at is before the current last observed at")
|
||||
}
|
||||
|
||||
apiKey.LastObservedAt = lastObservedAt
|
||||
apiKey.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableFactorAPIKey
|
||||
|
||||
@@ -137,7 +162,7 @@ func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountFactorAPIkeyInvalidInput, "name cannot be empty")
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
|
||||
}
|
||||
|
||||
*key = PostableFactorAPIKey(temp)
|
||||
@@ -153,7 +178,7 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountFactorAPIkeyInvalidInput, "name cannot be empty")
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
|
||||
}
|
||||
|
||||
*key = UpdatableFactorAPIKey(temp)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
|
||||
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
|
||||
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
|
||||
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
|
||||
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
|
||||
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
|
||||
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
|
||||
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
|
||||
ErrCodeServiceAccountOperationUnsupported = errors.MustNewCode("service_account_operation_unsupported")
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -27,25 +28,31 @@ var (
|
||||
ValidStatus = []valuer.String{StatusActive, StatusDisabled}
|
||||
)
|
||||
|
||||
var (
|
||||
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
|
||||
)
|
||||
|
||||
type StorableServiceAccount struct {
|
||||
bun.BaseModel `bun:"table:service_account,alias:service_account"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `bun:"name"`
|
||||
Email string `bun:"email"`
|
||||
Status valuer.String `bun:"status"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Name string `bun:"name"`
|
||||
Email string `bun:"email"`
|
||||
Status valuer.String `bun:"status"`
|
||||
OrgID string `bun:"org_id"`
|
||||
DeletedAt time.Time `bun:"deleted_at"`
|
||||
}
|
||||
|
||||
type ServiceAccount struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `json:"name" required:"true"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
Roles []string `json:"roles" required:"true" nullable:"false"`
|
||||
Status valuer.String `json:"status" required:"true"`
|
||||
OrgID valuer.UUID `json:"orgID" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
Roles []string `json:"roles" required:"true" nullable:"false"`
|
||||
Status valuer.String `json:"status" required:"true"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
DeletedAt time.Time `json:"deletedAt" required:"true"`
|
||||
}
|
||||
|
||||
type PostableServiceAccount struct {
|
||||
@@ -73,11 +80,12 @@ func NewServiceAccount(name string, email valuer.Email, roles []string, status v
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: name,
|
||||
Email: email,
|
||||
Roles: roles,
|
||||
Status: status,
|
||||
OrgID: orgID,
|
||||
Name: name,
|
||||
Email: email,
|
||||
Roles: roles,
|
||||
Status: status,
|
||||
OrgID: orgID,
|
||||
DeletedAt: time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +98,7 @@ func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccou
|
||||
Roles: roles,
|
||||
Status: storableServiceAccount.Status,
|
||||
OrgID: valuer.MustNewUUID(storableServiceAccount.OrgID),
|
||||
DeletedAt: storableServiceAccount.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,22 +135,46 @@ func NewStorableServiceAccount(serviceAccount *ServiceAccount) *StorableServiceA
|
||||
Email: serviceAccount.Email.String(),
|
||||
Status: serviceAccount.Status,
|
||||
OrgID: serviceAccount.OrgID.String(),
|
||||
DeletedAt: serviceAccount.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) {
|
||||
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) error {
|
||||
if err := sa.ErrIfDisabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sa.Name = name
|
||||
sa.Email = email
|
||||
sa.Roles = roles
|
||||
sa.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sa *ServiceAccount) UpdateStatus(status valuer.String) {
|
||||
func (sa *ServiceAccount) UpdateStatus(status valuer.String) error {
|
||||
if err := sa.ErrIfDisabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sa.Status = status
|
||||
sa.UpdatedAt = time.Now()
|
||||
sa.DeletedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sa *ServiceAccount) ErrIfDisabled() error {
|
||||
if sa.Status == StatusDisabled {
|
||||
return errors.New(errors.TypeUnsupported, ErrCodeServiceAccountOperationUnsupported, "this operation is not supported for disabled service account")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*FactorAPIKey, error) {
|
||||
if err := sa.ErrIfDisabled(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
@@ -161,7 +194,7 @@ func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*Facto
|
||||
Name: name,
|
||||
Key: encodedKey,
|
||||
ExpiresAt: expiresAt,
|
||||
LastUsed: time.Now(),
|
||||
LastObservedAt: time.Now(),
|
||||
ServiceAccountID: sa.ID,
|
||||
}, nil
|
||||
}
|
||||
@@ -204,8 +237,8 @@ func (sa *PostableServiceAccount) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name cannot be empty")
|
||||
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
|
||||
}
|
||||
|
||||
if len(temp.Roles) == 0 {
|
||||
@@ -224,8 +257,8 @@ func (sa *UpdatableServiceAccount) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name cannot be empty")
|
||||
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
|
||||
}
|
||||
|
||||
if len(temp.Roles) == 0 {
|
||||
@@ -244,8 +277,8 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(ValidStatus, temp.Status) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, ValidStatus)
|
||||
if temp.Status != StatusDisabled {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, StatusDisabled)
|
||||
}
|
||||
|
||||
*sa = UpdatableServiceAccountStatus(temp)
|
||||
|
||||
@@ -10,6 +10,7 @@ type Store interface {
|
||||
// Service Account
|
||||
Create(context.Context, *StorableServiceAccount) error
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableServiceAccount, error)
|
||||
GetActiveByOrgIDAndName(context.Context, valuer.UUID, string) (*StorableServiceAccount, error)
|
||||
GetByID(context.Context, valuer.UUID) (*StorableServiceAccount, error)
|
||||
List(context.Context, valuer.UUID) ([]*StorableServiceAccount, error)
|
||||
Update(context.Context, valuer.UUID, *StorableServiceAccount) error
|
||||
|
||||
Reference in New Issue
Block a user