Add Events view (#119)

This commit is contained in:
Misha Bragin
2023-01-02 17:29:11 +01:00
committed by GitHub
parent 0361825e04
commit 29ab28847d
14 changed files with 410 additions and 12 deletions

View File

@@ -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() {
<Route path="/routes" component={withOidcSecure(Routes)}/>
<Route path="/users" component={withOidcSecure(Users)}/>
<Route path="/dns" component={withOidcSecure(DNS)}/>
<Route path="/activity" component={withOidcSecure(Activity)}/>
</Switch>
</Content>
<FooterComponent/>

View File

@@ -39,13 +39,14 @@ const Navbar = () => {
{label: (<Link to="/acls">Access Control</Link>), key: '/acls'},
{label: (<Link to="/routes">Network Routes</Link>), key: '/routes'},
{ label: (<Link to="/dns">DNS</Link>), key: '/dns' },
{label: (<Link to="/users">Users</Link>), key: '/users'}
{label: (<Link to="/users">Users</Link>), key: '/users'},
{label: (<Link to="/activity">Activity</Link>), 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});

View File

@@ -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',
)<RequestPayload<null>, Event[], ApiError>(),
};
export type ActionTypes = ActionType<typeof actions>;
export default actions;

7
src/store/event/index.ts Normal file
View File

@@ -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 };

View File

@@ -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<Event[], ActionTypes>(initialState.data as Event[])
.handleAction(actions.getEvents.success,(_, action) => action.payload)
.handleAction(actions.getEvents.failure, () => []);
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
.handleAction(actions.getEvents.request, () => true)
.handleAction(actions.getEvents.success, () => false)
.handleAction(actions.getEvents.failure, () => false);
const failed = createReducer<ApiError | null, ActionTypes>(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,
});

23
src/store/event/sagas.ts Normal file
View File

@@ -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<typeof actions.getEvents.request>): Generator {
try {
const effect = yield call(service.getEvents, action.payload);
const response = effect as ApiResponse<Event[]>;
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),
]);
}

View File

@@ -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<null>): Promise<ApiResponse<Event[]>> {
return apiClient.get<Event[]>(
`/api/events`,
payload
);
}
};

9
src/store/event/types.ts Normal file
View File

@@ -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 }
}

View File

@@ -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 };

View File

@@ -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
};

View File

@@ -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
});

View File

@@ -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(' ')
}

272
src/views/Activity.tsx Normal file
View File

@@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
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 = <Text>{event.activity}</Text>
switch (event.activity_code) {
case "peer.group.add":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> added to peer</Text> </Row>
case "peer.group.delete":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> removed from peer</Text>
</Row>
case "user.group.add":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> added to user</Text> </Row>
case "user.group.delete":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> removed from user</Text>
</Row>
case "setupkey.group.add":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> added to setup key</Text>
</Row>
case "setupkey.group.delete":
return <Row> <Text>Group <Text type="secondary">{event.meta.group}</Text> removed setup key</Text>
</Row>
}
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 = <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{key.name}</Text> </Row>
<Row> <Text type="secondary">Setup Key</Text> </Row>
</span>
}
break
default:
if (user) {
body = <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{user.name ? user.name : user.id}</Text> </Row>
<Row> <Text type="secondary">{user.email ? user.email : "User"}</Text> </Row>
</span>
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 <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.name}</Text> </Row>
<Row> <Text type="secondary">Rule</Text> </Row>
</span>
case "setupkey.add":
case "setupkey.revoke":
case "setupkey.update":
case "setupkey.overuse":
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.name}</Text> </Row>
<Row> <Text
type="secondary">{capitalize(event.meta.type)} setup key ({event.meta.key})</Text> </Row>
</span>
case "group.add":
case "group.update":
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.name}</Text> </Row>
<Row> <Text type="secondary">Group</Text> </Row>
</span>
case "setupkey.peer.add":
case "user.peer.add":
case "user.peer.delete":
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.fqdn}</Text> </Row>
<Row> <Text type="secondary">{event.meta.ip}</Text> </Row>
</span>
case "user.group.add":
case "user.group.delete":
if (user) {
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{user.name ? user.name : user.id}</Text> </Row>
<Row> <Text type="secondary">{user.email ? user.email : "User"}</Text> </Row>
</span>
}
return "n/a"
case "setupkey.group.add":
case "setupkey.group.delete":
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.setupkey}</Text> </Row>
<Row> <Text type="secondary">Setup Key</Text> </Row>
</span>
case "peer.group.add":
case "peer.group.delete":
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{event.meta.peer_fqdn}</Text> </Row>
<Row> <Text type="secondary">{event.meta.peer_ip}</Text> </Row>
</span>
case "user.invite":
if (user) {
return <span style={{height: "auto", whiteSpace: "normal", textAlign: "left"}}>
<Row> <Text>{user.name ? user.name : user.id}</Text> </Row>
<Row> <Text type="secondary">{user.email ? user.email : "User"}</Text> </Row>
</span>
}
}
return event.target_id
}
return (
<>
<Container style={{paddingTop: "40px"}}>
<Row>
<Col span={24}>
<Title level={4}>Activity</Title>
<Paragraph>Here you can see all the account and network activity events</Paragraph>
<Space direction="vertical" size="large" style={{display: 'flex'}}>
<Row gutter={[16, 24]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
placeholder="Search..." onChange={onChangeTextToSearch}/>
</Col>
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Space size="middle">
<Select value={pageSize.toString()} options={pageSizeOptions}
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
</Row>
{failed &&
<Alert message={failed.message} description={failed.data ? failed.data.message : " "}
type="error" showIcon
closable/>
}
<Card bodyStyle={{padding: 0}}>
<Table
pagination={{
pageSize,
showSizeChanger: false,
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} users`)
}}
className="card-table"
showSorterTooltip={false}
scroll={{x: true}}
loading={tableSpin(loading)}
dataSource={dataTable}
size="small"
>
<Column title="Timestamp" dataIndex="timestamp"
render={(text, record, index) => {
return formatDateTime(text)
}}
/>
<Column title="Activity" dataIndex="activity"
render={(text, record, index) => {
return renderActivity(record as EventDataTable)
}}
/>
<Column title="Initiated By" dataIndex="initiator_id"
render={(text, record, index) => {
return renderInitiator(record as EventDataTable)
}}
/>
<Column title="Target" dataIndex="target_id"
render={(text, record, index) => {
return renderTarget(record as EventDataTable)
}}
/>
</Table>
</Card>
</Space>
</Col>
</Row>
</Container>
<UserUpdate/>
</>
)
}
export default Activity;

View File

@@ -110,7 +110,7 @@ export const Users = () => {
const filterDataTable = (): User[] => {
const t = textToSearch.toLowerCase().trim()
let f: User[] = filter(users, (f: User) =>
((f.email || f.id).toLowerCase().includes(t) || f.name.includes(t) || f.role.includes(t) || t === "")
((f.email || f.id).toLowerCase().includes(t) || f.name.toLowerCase().includes(t) || f.role.includes(t) || t === "")
) as User[]
return f
}