diff --git a/src/api/ros/recommendations.test.ts b/src/api/ros/recommendations.test.ts new file mode 100644 index 000000000..e06a7d679 --- /dev/null +++ b/src/api/ros/recommendations.test.ts @@ -0,0 +1,10 @@ +import { RosType } from 'api/ros/ros'; +import axios from 'axios'; + +import { runRos } from './recommendations'; + +test('api run reports calls axios get', () => { + const query = 'filter[resolution]=daily'; + runRos(RosType.cost, query); + expect(axios.get).toBeCalledWith(`reports/openshift/costs/?${query}`); +}); diff --git a/src/api/ros/recommendations.ts b/src/api/ros/recommendations.ts new file mode 100644 index 000000000..d95c4a5db --- /dev/null +++ b/src/api/ros/recommendations.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +import type { Ros, RosData, RosItem, RosItemValue, RosMeta, RosValue } from './ros'; +import { RosType } from './ros'; + +export interface RosRosItem extends RosItem { + capacity?: RosValue; + cluster?: string; + clusters?: string[]; + limit?: RosValue; + node?: string; + project?: string; + request?: RosValue; +} + +export interface RosRosData extends RosData { + // TBD... +} + +export interface RosRosMeta extends RosMeta { + total?: { + capacity?: RosValue; + cost?: RosItemValue; + infrastructure?: RosItemValue; + limit?: RosValue; + request?: RosValue; + supplementary?: RosItemValue; + usage?: RosValue; + }; +} + +export interface RosRos extends Ros { + meta: RosRosMeta; + data: RosRosData[]; +} + +export const RosTypePaths: Partial> = { + [RosType.cost]: 'reports/openshift/costs/', +}; + +export function runRos(reportType: RosType, query: string) { + const path = RosTypePaths[reportType]; + return axios.get(`${path}?${query}`); +} diff --git a/src/api/ros/ros.ts b/src/api/ros/ros.ts new file mode 100644 index 000000000..163821f4f --- /dev/null +++ b/src/api/ros/ros.ts @@ -0,0 +1,74 @@ +import type { PagedMetaData, PagedResponse } from 'api/api'; + +export interface RosValue { + units?: string; + value?: number; +} + +export interface RosItemValue { + markup?: RosValue; + raw?: RosValue; + total?: RosValue; + usage: RosValue; +} + +export interface RosItem { + alias?: string; + classification?: string; // Platform. + cost?: RosItemValue; + date?: string; + default_project?: string; // 'True' or 'False' + delta_percent?: number; + delta_value?: number; + id?: string; + infrastructure?: RosItemValue; + source_uuid?: string; + supplementary?: RosItemValue; +} + +// Additional props for group_by[org_unit_id] +export interface RosData { + date?: string; + values?: RosItem[]; +} + +export interface RosMeta extends PagedMetaData { + category?: string[]; + count: number; + delta?: { + percent: number; + value: number; + }; + filter?: { + [filter: string]: any; + }; + group_by?: { + [group: string]: string[]; + }; + order_by?: { + [order: string]: string; + }; + others?: number; + total?: { + capacity?: RosValue; + cost?: RosItemValue; + count?: RosValue; // Workaround for https://github.com/project-koku/koku/issues/1395 + infrastructure?: RosItemValue; + limit?: RosValue; + request?: RosValue; + supplementary?: RosItemValue; + usage?: RosValue; + }; +} + +export interface Ros extends PagedResponse {} + +// eslint-disable-next-line no-shadow +export const enum RosType { + cost = 'cost', +} + +// eslint-disable-next-line no-shadow +export const enum RosPathsType { + recommendation = 'recommendation', +} diff --git a/src/api/ros/rosUtils.ts b/src/api/ros/rosUtils.ts new file mode 100644 index 000000000..a789f3584 --- /dev/null +++ b/src/api/ros/rosUtils.ts @@ -0,0 +1,13 @@ +import { runRos as runRecommendation } from './recommendations'; +import type { RosType } from './ros'; +import { RosPathsType } from './ros'; + +export function runRos(rosPathsType: RosPathsType, rosType: RosType, query: string) { + let result; + switch (rosPathsType) { + case RosPathsType.recommendation: + result = runRecommendation(rosType, query); + break; + } + return result; +} diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 1db1ca3d8..87944e5ee 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -39,6 +39,7 @@ import { orgReducer, orgStateKey } from 'store/orgs'; import { priceListReducer, priceListStateKey } from 'store/priceList'; import { reportReducer, reportStateKey } from 'store/reports'; import { resourceReducer, resourceStateKey } from 'store/resources'; +import { rosReducer, rosStateKey } from 'store/ros'; import { sourcesReducer, sourcesStateKey } from 'store/sourceSettings'; import { tagReducer, tagStateKey } from 'store/tags'; import type { StateType } from 'typesafe-actions'; @@ -90,6 +91,7 @@ export const rootReducer = combineReducers({ [rhelCostOverviewStateKey]: rhelCostOverviewReducer, [rhelDashboardStateKey]: rhelDashboardReducer, [rhelHistoricalDataStateKey]: rhelHistoricalDataReducer, + [rosStateKey]: rosReducer, [sourcesStateKey]: sourcesReducer, [tagStateKey]: tagReducer, [uiStateKey]: uiReducer, diff --git a/src/store/ros/__snapshots__/ros.test.ts.snap b/src/store/ros/__snapshots__/ros.test.ts.snap new file mode 100644 index 000000000..c40443195 --- /dev/null +++ b/src/store/ros/__snapshots__/ros.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`default state 1`] = ` +{ + "byId": Map {}, + "errors": Map {}, + "fetchStatus": Map {}, +} +`; diff --git a/src/store/ros/index.ts b/src/store/ros/index.ts new file mode 100644 index 000000000..80e11b672 --- /dev/null +++ b/src/store/ros/index.ts @@ -0,0 +1,8 @@ +import * as rosActions from './rosActions'; +import { rosStateKey } from './rosCommon'; +import type { CachedRos, RosAction, RosState } from './rosReducer'; +import { rosReducer } from './rosReducer'; +import * as rosSelectors from './rosSelectors'; + +export type { RosAction, CachedRos, RosState }; +export { rosActions, rosReducer, rosSelectors, rosStateKey }; diff --git a/src/store/ros/ros.test.ts b/src/store/ros/ros.test.ts new file mode 100644 index 000000000..fad9570e0 --- /dev/null +++ b/src/store/ros/ros.test.ts @@ -0,0 +1,89 @@ +jest.mock('api/ros/rosUtils'); + +import { waitFor } from '@testing-library/react'; +import type { Ros } from 'api/ros/ros'; +import { RosPathsType, RosType } from 'api/ros/ros'; +import { runRos } from 'api/ros/rosUtils'; +import { FetchStatus } from 'store/common'; +import { createMockStoreCreator } from 'store/mockStore'; + +import * as actions from './rosActions'; +import { rosStateKey } from './rosCommon'; +import { rosReducer } from './rosReducer'; +import * as selectors from './rosSelectors'; + +const createRossStore = createMockStoreCreator({ + [rosStateKey]: rosReducer, +}); + +const runRosMock = runRos as jest.Mock; + +const mockRos: Ros = { + data: [], + total: { + value: 100, + units: 'USD', + }, +} as any; + +const rosType = RosType.cost; +const rosPathsType = RosPathsType.recommendation; +const rosQueryString = 'rosQueryString'; + +runRosMock.mockResolvedValue({ data: mockRos }); +global.Date.now = jest.fn(() => 12345); + +jest.spyOn(actions, 'fetchRos'); +jest.spyOn(selectors, 'selectRosFetchStatus'); + +test('default state', () => { + const store = createRossStore(); + expect(selectors.selectRosState(store.getState())).toMatchSnapshot(); +}); + +test('fetch ros success', async () => { + const store = createRossStore(); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + expect(runRosMock).toBeCalled(); + expect(selectors.selectRosFetchStatus(store.getState(), rosPathsType, rosType, rosQueryString)).toBe( + FetchStatus.inProgress + ); + await waitFor(() => expect(selectors.selectRosFetchStatus).toHaveBeenCalled()); + const finishedState = store.getState(); + expect(selectors.selectRosFetchStatus(finishedState, rosPathsType, rosType, rosQueryString)).toBe( + FetchStatus.complete + ); + expect(selectors.selectRosError(finishedState, rosPathsType, rosType, rosQueryString)).toBe(null); +}); + +test('fetch ros failure', async () => { + const store = createRossStore(); + const error = Symbol('ros error'); + runRosMock.mockRejectedValueOnce(error); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + expect(runRos).toBeCalled(); + expect(selectors.selectRosFetchStatus(store.getState(), rosPathsType, rosType, rosQueryString)).toBe( + FetchStatus.inProgress + ); + await waitFor(() => expect(selectors.selectRosFetchStatus).toHaveBeenCalled()); + const finishedState = store.getState(); + expect(selectors.selectRosFetchStatus(finishedState, rosPathsType, rosType, rosQueryString)).toBe( + FetchStatus.complete + ); + expect(selectors.selectRosError(finishedState, rosPathsType, rosType, rosQueryString)).toBe(error); +}); + +test('does not fetch ros if the request is in progress', () => { + const store = createRossStore(); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + expect(runRos).toHaveBeenCalledTimes(1); +}); + +test('ros is not refetched if it has not expired', async () => { + const store = createRossStore(); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + await waitFor(() => expect(actions.fetchRos).toHaveBeenCalled()); + store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString)); + expect(runRos).toHaveBeenCalledTimes(1); +}); diff --git a/src/store/ros/rosActions.ts b/src/store/ros/rosActions.ts new file mode 100644 index 000000000..9a00a3803 --- /dev/null +++ b/src/store/ros/rosActions.ts @@ -0,0 +1,59 @@ +import type { Ros } from 'api/ros/ros'; +import type { RosPathsType, RosType } from 'api/ros/ros'; +import { runRos } from 'api/ros/rosUtils'; +import type { AxiosError } from 'axios'; +import type { ThunkAction } from 'store/common'; +import { FetchStatus } from 'store/common'; +import type { RootState } from 'store/rootReducer'; +import { createAction } from 'typesafe-actions'; + +import { getFetchId } from './rosCommon'; +import { selectRos, selectRosFetchStatus } from './rosSelectors'; + +const expirationMS = 30 * 60 * 1000; // 30 minutes + +interface RosActionMeta { + fetchId: string; +} + +export const fetchRosRequest = createAction('ros/request')(); +export const fetchRosSuccess = createAction('ros/success')(); +export const fetchRosFailure = createAction('ros/failure')(); + +export function fetchRos(rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string): ThunkAction { + return (dispatch, getState) => { + if (!isRosExpired(getState(), rosPathsType, rosType, rosQueryString)) { + return; + } + + const meta: RosActionMeta = { + fetchId: getFetchId(rosPathsType, rosType, rosQueryString), + }; + + dispatch(fetchRosRequest(meta)); + runRos(rosPathsType, rosType, rosQueryString) + .then(res => { + // See https://github.com/project-koku/koku-ui/pull/580 + // const repsonseData = dropCurrentMonthData(res, query); + dispatch(fetchRosSuccess(res.data, meta)); + }) + .catch(err => { + dispatch(fetchRosFailure(err, meta)); + }); + }; +} + +function isRosExpired(state: RootState, rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string) { + const ros = selectRos(state, rosPathsType, rosType, rosQueryString); + const fetchStatus = selectRosFetchStatus(state, rosPathsType, rosType, rosQueryString); + if (fetchStatus === FetchStatus.inProgress) { + return false; + } + + if (!ros) { + return true; + } + + const now = Date.now(); + return now > ros.timeRequested + expirationMS; +} diff --git a/src/store/ros/rosCommon.ts b/src/store/ros/rosCommon.ts new file mode 100644 index 000000000..8c61d8d8c --- /dev/null +++ b/src/store/ros/rosCommon.ts @@ -0,0 +1,6 @@ +import type { RosPathsType, RosType } from 'api/ros/ros'; +export const rosStateKey = 'ros'; + +export function getFetchId(rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string) { + return `${rosPathsType}--${rosType}--${rosQueryString}`; +} diff --git a/src/store/ros/rosReducer.ts b/src/store/ros/rosReducer.ts new file mode 100644 index 000000000..adeb89469 --- /dev/null +++ b/src/store/ros/rosReducer.ts @@ -0,0 +1,62 @@ +import type { Ros } from 'api/ros/ros'; +import type { AxiosError } from 'axios'; +import { FetchStatus } from 'store/common'; +import { resetState } from 'store/ui/uiActions'; +import type { ActionType } from 'typesafe-actions'; +import { getType } from 'typesafe-actions'; + +import { fetchRosFailure, fetchRosRequest, fetchRosSuccess } from './rosActions'; + +export interface CachedRos extends Ros { + timeRequested: number; +} + +export type RosState = Readonly<{ + byId: Map; + fetchStatus: Map; + errors: Map; +}>; + +const defaultState: RosState = { + byId: new Map(), + fetchStatus: new Map(), + errors: new Map(), +}; + +export type RosAction = ActionType< + typeof fetchRosFailure | typeof fetchRosRequest | typeof fetchRosSuccess | typeof resetState +>; + +export function rosReducer(state = defaultState, action: RosAction): RosState { + switch (action.type) { + case getType(resetState): + state = defaultState; + return state; + + case getType(fetchRosRequest): + return { + ...state, + fetchStatus: new Map(state.fetchStatus).set(action.payload.fetchId, FetchStatus.inProgress), + }; + + case getType(fetchRosSuccess): + return { + ...state, + fetchStatus: new Map(state.fetchStatus).set(action.meta.fetchId, FetchStatus.complete), + byId: new Map(state.byId).set(action.meta.fetchId, { + ...action.payload, + timeRequested: Date.now(), + }), + errors: new Map(state.errors).set(action.meta.fetchId, null), + }; + + case getType(fetchRosFailure): + return { + ...state, + fetchStatus: new Map(state.fetchStatus).set(action.meta.fetchId, FetchStatus.complete), + errors: new Map(state.errors).set(action.meta.fetchId, action.payload), + }; + default: + return state; + } +} diff --git a/src/store/ros/rosSelectors.ts b/src/store/ros/rosSelectors.ts new file mode 100644 index 000000000..883211d6b --- /dev/null +++ b/src/store/ros/rosSelectors.ts @@ -0,0 +1,23 @@ +import type { RosPathsType, RosType } from 'api/ros/ros'; +import type { RootState } from 'store/rootReducer'; + +import { getFetchId, rosStateKey } from './rosCommon'; + +export const selectRosState = (state: RootState) => state[rosStateKey]; + +export const selectRos = (state: RootState, rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string) => + selectRosState(state).byId.get(getFetchId(rosPathsType, rosType, rosQueryString)); + +export const selectRosFetchStatus = ( + state: RootState, + rosPathsType: RosPathsType, + rosType: RosType, + rosQueryString: string +) => selectRosState(state).fetchStatus.get(getFetchId(rosPathsType, rosType, rosQueryString)); + +export const selectRosError = ( + state: RootState, + rosPathsType: RosPathsType, + rosType: RosType, + rosQueryString: string +) => selectRosState(state).errors.get(getFetchId(rosPathsType, rosType, rosQueryString));