From 29ab28847dd1c298a7cc67fd542e0eda2dfcefa5 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Mon, 2 Jan 2023 17:29:11 +0100 Subject: [PATCH] Add Events view (#119) --- src/App.tsx | 2 + src/components/Navbar.tsx | 5 +- src/store/event/actions.ts | 14 ++ src/store/event/index.ts | 7 + src/store/event/reducer.ts | 38 ++++++ src/store/event/sagas.ts | 23 ++++ src/store/event/service.ts | 12 ++ src/store/event/types.ts | 9 ++ src/store/index.ts | 2 + src/store/root-action.ts | 18 +-- src/store/root-reducer.ts | 4 +- src/utils/common.js | 14 ++ src/views/Activity.tsx | 272 +++++++++++++++++++++++++++++++++++++ src/views/Users.tsx | 2 +- 14 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 src/store/event/actions.ts create mode 100644 src/store/event/index.ts create mode 100644 src/store/event/reducer.ts create mode 100644 src/store/event/sagas.ts create mode 100644 src/store/event/service.ts create mode 100644 src/store/event/types.ts create mode 100644 src/views/Activity.tsx diff --git a/src/App.tsx b/src/App.tsx index 10be792..fcea555 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import {useGetAccessTokenSilently} from "./utils/token"; import {User} from "./store/user/types"; import {SecureLoading} from "./components/Loading"; import DNS from "./views/DNS"; +import Activity from "./views/Activity"; @@ -105,6 +106,7 @@ function App() { + diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9deb6c3..1f51560 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -39,13 +39,14 @@ const Navbar = () => { {label: (Access Control), key: '/acls'}, {label: (Network Routes), key: '/routes'}, { label: (DNS), key: '/dns' }, - {label: (Users), key: '/users'} + {label: (Users), key: '/users'}, + {label: (Activity), key: '/activity'} ] as ItemType[] const userEmailKey = 'user-email' const userLogoutKey = 'user-logout' const userDividerKey = 'user-divider' - const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns"] + const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity"] const [menuItems, setMenuItems] = useState(items) const logoutWithRedirect = () => logout("/", {client_id: config.clientId}); diff --git a/src/store/event/actions.ts b/src/store/event/actions.ts new file mode 100644 index 0000000..52dea0a --- /dev/null +++ b/src/store/event/actions.ts @@ -0,0 +1,14 @@ +import {ActionType, createAsyncAction} from 'typesafe-actions'; +import {Event} from './types'; +import {ApiError, RequestPayload} from '../../services/api-client/types'; + +const actions = { + getEvents: createAsyncAction( + 'GET_EVENTS_REQUEST', + 'GET_EVENTS_SUCCESS', + 'GET_EVENTS_FAILURE', + ), Event[], ApiError>(), +}; + +export type ActionTypes = ActionType; +export default actions; diff --git a/src/store/event/index.ts b/src/store/event/index.ts new file mode 100644 index 0000000..1f10d21 --- /dev/null +++ b/src/store/event/index.ts @@ -0,0 +1,7 @@ +import actions, { ActionTypes as _actionTypes } from './actions'; +import reducer from './reducer'; +import sagas from './sagas'; + +export type ActionTypes = _actionTypes; + +export { actions, reducer, sagas }; diff --git a/src/store/event/reducer.ts b/src/store/event/reducer.ts new file mode 100644 index 0000000..0a2b9c4 --- /dev/null +++ b/src/store/event/reducer.ts @@ -0,0 +1,38 @@ +import { createReducer } from 'typesafe-actions'; +import { combineReducers } from 'redux'; +import { Event } from './types'; +import actions, { ActionTypes } from './actions'; +import {ApiError, CreateResponse} from "../../services/api-client/types"; + +type StateType = Readonly<{ + data: Event[] | null; + loading: boolean; + failed: ApiError | null; +}>; + +const initialState: StateType = { + data: [], + loading: false, + failed: null, +}; + +const data = createReducer(initialState.data as Event[]) + .handleAction(actions.getEvents.success,(_, action) => action.payload) + .handleAction(actions.getEvents.failure, () => []); + +const loading = createReducer(initialState.loading) + .handleAction(actions.getEvents.request, () => true) + .handleAction(actions.getEvents.success, () => false) + .handleAction(actions.getEvents.failure, () => false); + +const failed = createReducer(initialState.failed) + .handleAction(actions.getEvents.request, () => null) + .handleAction(actions.getEvents.success, () => null) + .handleAction(actions.getEvents.failure, (store, action) => action.payload); + + +export default combineReducers({ + data, + loading, + failed, +}); diff --git a/src/store/event/sagas.ts b/src/store/event/sagas.ts new file mode 100644 index 0000000..b102c5c --- /dev/null +++ b/src/store/event/sagas.ts @@ -0,0 +1,23 @@ +import {all, call, put, takeLatest} from 'redux-saga/effects'; +import {ApiError, ApiResponse} from '../../services/api-client/types'; +import {Event} from './types' +import service from './service'; +import actions from './actions'; + +export function* getEvents(action: ReturnType): Generator { + try { + const effect = yield call(service.getEvents, action.payload); + const response = effect as ApiResponse; + + yield put(actions.getEvents.success(response.body)); + } catch (err) { + yield put(actions.getEvents.failure(err as ApiError)); + } +} + +export default function* sagas(): Generator { + yield all([ + takeLatest(actions.getEvents.request, getEvents), + ]); +} + diff --git a/src/store/event/service.ts b/src/store/event/service.ts new file mode 100644 index 0000000..10716db --- /dev/null +++ b/src/store/event/service.ts @@ -0,0 +1,12 @@ +import {ApiResponse, RequestPayload} from '../../services/api-client/types'; +import { apiClient } from '../../services/api-client'; +import {Event} from './types'; + +export default { + async getEvents(payload:RequestPayload): Promise> { + return apiClient.get( + `/api/events`, + payload + ); + } +}; diff --git a/src/store/event/types.ts b/src/store/event/types.ts new file mode 100644 index 0000000..3e56e5d --- /dev/null +++ b/src/store/event/types.ts @@ -0,0 +1,9 @@ +export interface Event { + id: string; + timestamp: string + activity: string + activity_code: string + initiator_id: string + target_id: string + meta: { [key: string]: string } +} \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 43cd043..f7db4e6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,6 +9,7 @@ import { sagas as ruleSagas } from './rule'; import { sagas as groupSagas } from './group'; import { sagas as routeSagas } from './route'; import { sagas as nameserverGroupSagas } from './nameservers'; +import { sagas as eventSagas } from './event'; import rootReducer from './root-reducer'; import { apiClient } from '../services/api-client'; @@ -27,5 +28,6 @@ sagaMiddleware.run(ruleSagas); sagaMiddleware.run(groupSagas); sagaMiddleware.run(routeSagas); sagaMiddleware.run(nameserverGroupSagas); +sagaMiddleware.run(eventSagas); export { apiClient, rootReducer, store }; \ No newline at end of file diff --git a/src/store/root-action.ts b/src/store/root-action.ts index 5039686..ff3ce45 100644 --- a/src/store/root-action.ts +++ b/src/store/root-action.ts @@ -1,10 +1,11 @@ -import { actions as PeerActions } from './peer'; -import { actions as SetupKeyActions } from './setup-key'; -import { actions as UserActions } from './user'; -import { actions as GroupActions } from './group'; -import { actions as RuleActions } from './rule'; -import { actions as RouteActions } from './route'; -import { actions as NameServerGroupActions } from './nameservers'; +import {actions as PeerActions} from './peer'; +import {actions as SetupKeyActions} from './setup-key'; +import {actions as UserActions} from './user'; +import {actions as GroupActions} from './group'; +import {actions as RuleActions} from './rule'; +import {actions as RouteActions} from './route'; +import {actions as NameServerGroupActions} from './nameservers'; +import {actions as EventActions} from './event'; export default { peer: PeerActions, @@ -13,5 +14,6 @@ export default { group: GroupActions, rule: RuleActions, route: RouteActions, - nameserverGroup: NameServerGroupActions + nameserverGroup: NameServerGroupActions, + event: EventActions }; diff --git a/src/store/root-reducer.ts b/src/store/root-reducer.ts index 2751d85..781d763 100644 --- a/src/store/root-reducer.ts +++ b/src/store/root-reducer.ts @@ -7,6 +7,7 @@ import { reducer as group } from './group'; import { reducer as rule } from './rule'; import { reducer as route } from './route'; import { reducer as nameserverGroup } from './nameservers'; +import { reducer as event } from './event'; export default combineReducers({ peer, @@ -15,5 +16,6 @@ export default combineReducers({ group, rule, route, - nameserverGroup + nameserverGroup, + event }); diff --git a/src/utils/common.js b/src/utils/common.js index b6b6b3a..d6d9073 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -17,6 +17,20 @@ export const formatDate = date => { return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: '2-digit', month: 'short', day: 'numeric' }); } +export const capitalize = text => { + if (!text) { + return text + } + return text.charAt(0).toUpperCase() + text.slice(1) +} + +export const formatDateTime = date => { + if (new Date(date).getTime() > new Date("2099-12-31").getTime()) { + return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }); + } + return new Date(date).toLocaleDateString("en-GB", { weekday: 'short', year: '2-digit', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }); +} + export const classNames = (...classes) => { return classes.filter(Boolean).join(' ') } diff --git a/src/views/Activity.tsx b/src/views/Activity.tsx new file mode 100644 index 0000000..add792a --- /dev/null +++ b/src/views/Activity.tsx @@ -0,0 +1,272 @@ +import React, {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {actions as eventActions} from '../store/event'; +import {Container} from "../components/Container"; +import {Alert, Card, Col, Input, Row, Select, Space, Table, Typography,} from "antd"; +import {Event} from "../store/event/types"; +import {filter} from "lodash"; +import tableSpin from "../components/Spin"; +import {useGetAccessTokenSilently} from "../utils/token"; +import UserUpdate from "../components/UserUpdate"; +import {useOidcUser} from "@axa-fr/react-oidc"; +import {capitalize, formatDateTime} from "../utils/common"; +import {User} from "../store/user/types"; + +const {Title, Paragraph, Text} = Typography; +const {Column} = Table; + +interface EventDataTable extends Event { +} + +export const Activity = () => { + const {getAccessTokenSilently} = useGetAccessTokenSilently() + const {oidcUser} = useOidcUser(); + const dispatch = useDispatch() + + const events = useSelector((state: RootState) => state.event.data); + const failed = useSelector((state: RootState) => state.event.failed); + const loading = useSelector((state: RootState) => state.event.loading); + const users = useSelector((state: RootState) => state.user.data); + const setupKeys = useSelector((state: RootState) => state.setupKey.data); + + const [textToSearch, setTextToSearch] = useState(''); + const [pageSize, setPageSize] = useState(20); + const [dataTable, setDataTable] = useState([] as EventDataTable[]); + const pageSizeOptions = [ + {label: "5", value: "5"}, + {label: "10", value: "10"}, + {label: "15", value: "15"}, + {label: "20", value: "20"} + ] + + const transformDataTable = (d: Event[]): EventDataTable[] => { + return d.map(p => ({key: p.id, ...p} as EventDataTable)) + } + + useEffect(() => { + dispatch(eventActions.getEvents.request({getAccessTokenSilently: getAccessTokenSilently, payload: null})); + }, []) + useEffect(() => { + setDataTable(transformDataTable(events)) + }, [events]) + + useEffect(() => { + setDataTable(transformDataTable(filterDataTable())) + }, [textToSearch]) + + const filterDataTable = (): Event[] => { + const t = textToSearch.toLowerCase().trim() + let usrsMatch: User[] = filter(users, (u: User) => (u.name)?.toLowerCase().includes(t) || (u.email)?.toLowerCase().includes(t)) as User[] + let f: Event[] = filter(events, (f: Event) => + ((f.activity || f.id).toLowerCase().includes(t) || t === "" || usrsMatch.find(u => u.id === f.initiator_id)) + ) as Event[] + return f + } + + const onChangeTextToSearch = (e: React.ChangeEvent) => { + setTextToSearch(e.target.value) + }; + + const searchDataTable = () => { + const data = filterDataTable() + setDataTable(transformDataTable(data)) + } + + const onChangePageSize = (value: string) => { + setPageSize(parseInt(value.toString())) + } + + const renderActivity = (event: EventDataTable) => { + let body = {event.activity} + switch (event.activity_code) { + case "peer.group.add": + return Group {event.meta.group} added to peer + case "peer.group.delete": + return Group {event.meta.group} removed from peer + + case "user.group.add": + return Group {event.meta.group} added to user + case "user.group.delete": + return Group {event.meta.group} removed from user + + case "setupkey.group.add": + return Group {event.meta.group} added to setup key + + case "setupkey.group.delete": + return Group {event.meta.group} removed setup key + + } + return body + } + const renderInitiator = (event: EventDataTable) => { + let body = <> + const user = users?.find(u => u.id === event.initiator_id) + switch (event.activity_code) { + case "setupkey.peer.add": + const key = setupKeys?.find(k => k.id === event.initiator_id) + if (key) { + body = + {key.name} + Setup Key + + } + break + default: + if (user) { + body = + {user.name ? user.name : user.id} + {user.email ? user.email : "User"} + + return body + } + } + + + return body + } + + const renderTarget = (event: EventDataTable) => { + if (event.activity_code === "account.create" || event.activity_code === "user.join") { + return "-" + } + const user = users?.find(u => u.id === event.target_id) + switch (event.activity_code) { + case "account.create": + case "user.join": + return "-" + case "rule.add": + case "rule.delete": + case "rule.update": + return + {event.meta.name} + Rule + + case "setupkey.add": + case "setupkey.revoke": + case "setupkey.update": + case "setupkey.overuse": + return + {event.meta.name} + {capitalize(event.meta.type)} setup key ({event.meta.key}) + + case "group.add": + case "group.update": + return + {event.meta.name} + Group + + case "setupkey.peer.add": + case "user.peer.add": + case "user.peer.delete": + return + {event.meta.fqdn} + {event.meta.ip} + + case "user.group.add": + case "user.group.delete": + if (user) { + return + {user.name ? user.name : user.id} + {user.email ? user.email : "User"} + + } + return "n/a" + case "setupkey.group.add": + case "setupkey.group.delete": + return + {event.meta.setupkey} + Setup Key + + + case "peer.group.add": + case "peer.group.delete": + return + {event.meta.peer_fqdn} + {event.meta.peer_ip} + + case "user.invite": + if (user) { + return + {user.name ? user.name : user.id} + {user.email ? user.email : "User"} + + } + + } + + return event.target_id + } + + return ( + <> + + + + Activity + Here you can see all the account and network activity events + + + + + + + +