From 74f9205be1fafa54da45998cb00c9feff3c46e64 Mon Sep 17 00:00:00 2001 From: Gala Date: Thu, 20 Oct 2022 15:04:36 +0200 Subject: [PATCH] create gateway details page --- explorer/src/api/constants.ts | 1 + explorer/src/api/index.ts | 21 ++- explorer/src/components/DetailTable.tsx | 93 ++++++---- explorer/src/components/Gateways.ts | 41 ++++- explorer/src/context/gateway.tsx | 104 +++++++++++ explorer/src/pages/GateDetail/index.tsx | 190 +++++++++++++++++++++ explorer/src/pages/Gateways/index.tsx | 63 +++++-- explorer/src/routes/network-components.tsx | 2 + explorer/src/typeDefs/explorer-api.ts | 19 ++- 9 files changed, 467 insertions(+), 67 deletions(-) create mode 100644 explorer/src/context/gateway.tsx create mode 100644 explorer/src/pages/GateDetail/index.tsx diff --git a/explorer/src/api/constants.ts b/explorer/src/api/constants.ts index 5c2b044261..fb7216b64f 100644 --- a/explorer/src/api/constants.ts +++ b/explorer/src/api/constants.ts @@ -14,6 +14,7 @@ export const VALIDATORS_API = `${VALIDATOR_BASE_URL}/validators`; export const BLOCK_API = `${VALIDATOR_API_BASE_URL}/block`; export const COUNTRY_DATA_API = `${API_BASE_URL}/countries`; export const UPTIME_STORY_API = `${VALIDATOR_API_BASE_URL}/api/v1/status/mixnode`; // add ID then '/history' to this. +export const UPTIME_STORY_API_GATEWAY = `${VALIDATOR_API_BASE_URL}/api/v1/status/gateway`; // add ID then '/history' or '/report' to this. // errors export const MIXNODE_API_ERROR = "We're having trouble finding that record, please try again or Contact Us."; diff --git a/explorer/src/api/index.ts b/explorer/src/api/index.ts index 5a9b40ef9a..328eb5f4bd 100644 --- a/explorer/src/api/index.ts +++ b/explorer/src/api/index.ts @@ -2,6 +2,7 @@ import { BLOCK_API, COUNTRY_DATA_API, GATEWAYS_API, + UPTIME_STORY_API_GATEWAY, MIXNODE_API, MIXNODE_PING, MIXNODES_API, @@ -15,6 +16,8 @@ import { DelegationsResponse, UniqDelegationsResponse, GatewayResponse, + GatewayReportResponse, + UptimeStoryResponse, MixNodeDescriptionResponse, MixNodeResponse, MixNodeResponseItem, @@ -23,7 +26,6 @@ import { StatsResponse, StatusResponse, SummaryOverviewResponse, - UptimeStoryResponse, ValidatorsResponse, } from '../typeDefs/explorer-api'; @@ -92,6 +94,23 @@ export class Api { return res.json(); }; + // static fetchGatewayByID = async (id: string): Promise => { + // const response = await fetch(`${GATEWAYS_API}/${id}`); + + // // when the mixnode is not found, returned undefined + // if (response.status === 404) { + // return undefined; + // } + + // return response.json(); + // }; + + static fetchGatewayUptimeStoryById = async (id: string): Promise => + (await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/history`)).json(); + + static fetchGatewayReportById = async (id: string): Promise => + (await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/report`)).json(); + static fetchValidators = async (): Promise => { const res = await fetch(VALIDATORS_API); const json = await res.json(); diff --git a/explorer/src/components/DetailTable.tsx b/explorer/src/components/DetailTable.tsx index 3957119cd3..b7d30a0e39 100644 --- a/explorer/src/components/DetailTable.tsx +++ b/explorer/src/components/DetailTable.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { Tooltip } from '@nymproject/react/tooltip/Tooltip'; import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard'; import { Box } from '@mui/system'; import { cellStyles } from './Universal-DataGrid'; import { currencyToString } from '../utils/currency'; +import { GatewayEnridedRowType } from './Gateways'; import { MixnodeRowType } from './MixNodes'; export type ColumnsType = { @@ -43,42 +46,60 @@ function formatCellValues(val: string | number, field: string) { export const DetailTable: React.FC<{ tableName: string; columnsData: ColumnsType[]; - rows: MixnodeRowType[]; -}> = ({ tableName, columnsData, rows }: UniversalTableProps) => ( - - - - - {columnsData?.map(({ field, title, flex }) => ( - - {title} - - ))} - - - - {rows.map((eachRow) => ( - - {columnsData?.map((_, index) => ( - - {formatCellValues(eachRow[columnsData[index].field], columnsData[index].field)} + rows: MixnodeRowType[] | GatewayEnridedRowType[]; +}> = ({ tableName, columnsData, rows }: UniversalTableProps) => { + const theme = useTheme(); + return ( + +
+ + + {columnsData?.map(({ field, title, flex, tooltipInfo }) => ( + + + {tooltipInfo && ( + + + + )} + {title} + ))} - ))} - -
-
-); + + + {rows.map((eachRow) => ( + + {columnsData?.map((_, index) => ( + + {formatCellValues(eachRow[columnsData[index].field], columnsData[index].field)} + + ))} + + ))} + + + + ); +}; diff --git a/explorer/src/components/Gateways.ts b/explorer/src/components/Gateways.ts index 9f05ff51b0..0bf1de5b85 100644 --- a/explorer/src/components/Gateways.ts +++ b/explorer/src/components/Gateways.ts @@ -1,4 +1,4 @@ -import { GatewayResponse } from '../typeDefs/explorer-api'; +import { GatewayResponse, GatewayResponseItem, GatewayReportResponse } from '../typeDefs/explorer-api'; export type GatewayRowType = { id: string; @@ -9,15 +9,40 @@ export type GatewayRowType = { location: string; }; +export type GatewayEnridedRowType = GatewayRowType & { + routing_score: string; + avg_uptime: string; + clients_port: number; + mix_port: number; +}; + export function gatewayToGridRow(arrayOfGateways: GatewayResponse): GatewayRowType[] { return !arrayOfGateways ? [] : arrayOfGateways.map((gw) => ({ - id: gw.owner, - owner: gw.owner, - identity_key: gw.gateway.identity_key || '', - location: gw?.gateway?.location || '', - bond: gw.pledge_amount.amount || 0, - host: gw.gateway.host || '', - })); + id: gw.owner, + owner: gw.owner, + identity_key: gw.gateway.identity_key || '', + location: gw?.gateway?.location || '', + bond: gw.pledge_amount.amount || 0, + host: gw.gateway.host || '', + })); +} + +export function gatewayEnrichedToGridRow( + gateway: GatewayResponseItem, + report: GatewayReportResponse, +): GatewayEnridedRowType { + return { + id: gateway.owner, + owner: gateway.owner, + identity_key: gateway.gateway.identity_key || '', + location: gateway?.gateway?.location || '', + bond: gateway.pledge_amount.amount || 0, + host: gateway.gateway.host || '', + clients_port: gateway.gateway.clients_port || 0, + mix_port: gateway.gateway.mix_port || 0, + routing_score: `${report.most_recent}%`, + avg_uptime: `${report.last_day}%`, + }; } diff --git a/explorer/src/context/gateway.tsx b/explorer/src/context/gateway.tsx new file mode 100644 index 0000000000..5460be4b41 --- /dev/null +++ b/explorer/src/context/gateway.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { ApiState, GatewayReportResponse, UptimeStoryResponse } from '../typeDefs/explorer-api'; +import { Api } from '../api'; + +/** + * This context provides the state for a single gateway by identity key. + */ + +interface GatewayState { + uptimeReport?: ApiState; + // uptimeStory?: ApiState; +} + +export const GatewayContext = React.createContext({}); + +export const useGatewayContext = (): React.ContextType => + React.useContext(GatewayContext); + +interface GatewayContextProviderProps { + gatewayIdentityKey: string; +} + +/** + * Provides a state context for a gateway by identity + * @param gatewayIdentityKey The identity key of the gateway + */ +export const GatewayContextProvider: React.FC = ({ gatewayIdentityKey, children }) => { + const [uptimeReport, fetchUptimeReportById, clearUptimeReportById] = useApiState( + gatewayIdentityKey, + Api.fetchGatewayReportById, + 'Failed to fetch gateway uptime report by id', + ); + + // const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] = useApiState( + // gatewayIdentityKey, + // Api.fetchUptimeStoryById, + // 'Failed to fetch gateway uptime history', + // ); + + React.useEffect(() => { + // when the identity key changes, remove all previous data + clearUptimeReportById(); + Promise.all([fetchUptimeReportById()]); + }, [gatewayIdentityKey]); + + const state = React.useMemo( + () => ({ + uptimeReport, + // uptimeStory, + }), + [ + { + uptimeReport, + // uptimeStory, + }, + ], + ); + + return {children}; +}; + +/** + * Custom hook to get data from the API by passing an id to a delegate method that fetches the data asynchronously + * @param id The id to fetch + * @param fn Delegate the fetching to this method (must take `(id: string)` as a parameter) + * @param errorMessage A static error message, to use when no dynamic error message is returned + */ +function useApiState( + id: string, + fn: (id: string) => Promise, + errorMessage: string, +): [ApiState | undefined, () => Promise>, () => void] { + // stores the state + const [value, setValue] = React.useState | undefined>(); + + // clear the value + const clearValueFn = () => setValue(undefined); + + // this provides a method to trigger the delegate to fetch data + const wrappedFetchFn = React.useCallback(async () => { + try { + // keep previous state and set to loading + setValue((prevState) => ({ ...prevState, isLoading: true })); + + // delegate to user function to get data and set if successful + const data = await fn(id); + const newValue: ApiState = { + isLoading: false, + data, + }; + setValue(newValue); + return newValue; + } catch (error) { + // return the caught error or create a new error with the static error message + const newValue: ApiState = { + error: error instanceof Error ? error : new Error(errorMessage), + isLoading: false, + }; + setValue(newValue); + return newValue; + } + }, [setValue, fn]); + return [value || { isLoading: true }, wrappedFetchFn, clearValueFn]; +} diff --git a/explorer/src/pages/GateDetail/index.tsx b/explorer/src/pages/GateDetail/index.tsx new file mode 100644 index 0000000000..a3298bbd33 --- /dev/null +++ b/explorer/src/pages/GateDetail/index.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; +import { Alert, AlertTitle, Box, CircularProgress, Grid, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { GatewayResponseItem } from '../../typeDefs/explorer-api'; +import { ColumnsType, DetailTable } from '../../components/DetailTable'; +import { gatewayEnrichedToGridRow, GatewayEnridedRowType } from '../../components/Gateways'; +import { ComponentError } from '../../components/ComponentError'; +import { ContentCard } from '../../components/ContentCard'; +import { TwoColSmallTable } from '../../components/TwoColSmallTable'; +import { UptimeChart } from '../../components/UptimeChart'; +// import { GatewayDetailSection } from '../../components/Gateways/DetailSection'; +import { GatewayContextProvider, useGatewayContext } from '../../context/gateway'; +import { useMainContext } from '../../context/main'; +import { Title } from '../../components/Title'; + +const columns: ColumnsType[] = [ + { + field: 'identity_key', + title: 'Identity Key', + headerAlign: 'left', + width: 230, + }, + { + field: 'bond', + title: 'Bond', + flex: 1, + headerAlign: 'left', + }, + { + field: 'routing_score', + title: 'Routing Score', + flex: 1, + headerAlign: 'left', + tooltipInfo: 'Estimated reward per epoch for this profit margin if your node is selected in the active set.', + }, + { + field: 'avg_uptime', + title: 'Avg. Score', + flex: 1, + headerAlign: 'left', + tooltipInfo: 'Estimated reward per epoch for this profit margin if your node is selected in the active set.', + }, + { + field: 'host', + title: 'IP', + headerAlign: 'left', + width: 99, + }, + { + field: 'location', + title: 'Location', + headerAlign: 'left', + flex: 1, + }, + { + field: 'owner', + title: 'Owner', + headerAlign: 'left', + flex: 1, + }, +]; + +/** + * Shows gateway details + */ +const PageGatewayDetailsWithState: React.FC<{ selectedGateway: GatewayResponseItem | undefined }> = ({ + selectedGateway, +}) => { + const [enrichGateway, setEnrichGateway] = React.useState(); + const [status, setStatus] = React.useState(); + const { uptimeReport } = useGatewayContext(); + + React.useEffect(() => { + if (uptimeReport?.data && selectedGateway) { + setEnrichGateway(gatewayEnrichedToGridRow(selectedGateway, uptimeReport.data)); + } + }, [uptimeReport, selectedGateway]); + + React.useEffect(() => { + if (enrichGateway) { + setStatus([enrichGateway?.mix_port, enrichGateway?.clients_port]); + } + }, [enrichGateway]); + + return ( + + + + <Grid container spacing={2} mt={1} mb={6}> + <Grid item xs={12}> + Gateway name & description + {/* {gatewayRow && description?.data && ( + <GatewayDetailSection gatewayRow={gatewayRow} mixnodeDescription={description.data} /> + )} */} + </Grid> + </Grid> + + <Grid container> + <Grid item xs={12}> + <DetailTable + columnsData={columns} + tableName="Gateway detail table" + rows={enrichGateway ? [enrichGateway] : []} + /> + </Grid> + </Grid> + + <Grid container spacing={2} mt={0}> + <Grid item xs={12} md={4}> + {status && ( + <ContentCard title="Gateway Status"> + <TwoColSmallTable + loading={false} + keys={['Mix port', 'Client WS API Port']} + values={status.map((each) => each)} + icons={status.map((elem) => !!elem) || [false, false]} + /> + </ContentCard> + )} + </Grid> + <Grid item xs={12} md={8}> + {/* {uptimeStory && ( + <ContentCard title="Routing Score"> + {uptimeStory.error && <ComponentError text="There was a problem retrieving routing score." />} + <UptimeChart loading={uptimeStory.isLoading} xLabel="date" uptimeStory={uptimeStory} /> + </ContentCard> + )} */} + </Grid> + </Grid> + </Box> + ); +}; + +/** + * Guard component to handle loading and not found states + */ +const PageGatewayDetailGuard: React.FC = () => { + const [selectedGateway, setSelectedGateway] = React.useState<GatewayResponseItem | undefined>(); + const { gateways } = useMainContext(); + const { id } = useParams<{ id: string | undefined }>(); + + React.useEffect(() => { + if (gateways?.data) { + setSelectedGateway(gateways.data.find((gateway) => gateway.gateway.identity_key === id)); + } + }, [gateways]); + + if (gateways?.isLoading) { + return <CircularProgress />; + } + + if (gateways?.error) { + // eslint-disable-next-line no-console + console.error(gateways?.error); + return ( + <Alert severity="error"> + Oh no! Could not load mixnode <code>{id || ''}</code> + </Alert> + ); + } + + // loaded, but not found + if (gateways && !gateways.isLoading && !gateways.data) { + return ( + <Alert severity="warning"> + <AlertTitle>Gateway not found</AlertTitle> + Sorry, we could not find a mixnode with id <code>{id || ''}</code> + </Alert> + ); + } + + return <PageGatewayDetailsWithState selectedGateway={selectedGateway} />; +}; + +/** + * Wrapper component that adds the mixnode content based on the `id` in the address URL + */ +export const PageGatewayDetail: React.FC = () => { + const { id } = useParams<{ id: string | undefined }>(); + + if (!id) { + return <Alert severity="error">Oh no! No mixnode identity key specified</Alert>; + } + + return ( + <GatewayContextProvider gatewayIdentityKey={id}> + <PageGatewayDetailGuard /> + </GatewayContextProvider> + ); +}; diff --git a/explorer/src/pages/Gateways/index.tsx b/explorer/src/pages/Gateways/index.tsx index 39f3619308..dc73ffe794 100644 --- a/explorer/src/pages/Gateways/index.tsx +++ b/explorer/src/pages/Gateways/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { Button, Card, Grid, Typography } from '@mui/material'; +import { Link as RRDLink, useParams, useNavigate } from 'react-router-dom'; +import { Button, Card, Grid, Typography, Link as MuiLink } from '@mui/material'; import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { SelectChangeEvent } from '@mui/material/Select'; import { useMainContext } from '../../context/main'; @@ -22,6 +23,7 @@ export const PageGateways: React.FC = () => { }; React.useEffect(() => { + console.log({ gateways }); if (searchTerm === '' && gateways?.data) { setFilteredGateways(gateways?.data); } else { @@ -42,19 +44,19 @@ export const PageGateways: React.FC = () => { }, [searchTerm, gateways?.data]); const columns: GridColDef[] = [ - { - field: 'owner', - headerName: 'Owner', - renderHeader: () => <CustomColumnHeading headingTitle="Owner" />, - width: 380, - headerAlign: 'left', - headerClassName: 'MuiDataGrid-header-override', - renderCell: (params: GridRenderCellParams) => ( - <Typography sx={cellStyles} data-testid="owner"> - {params.value} - </Typography> - ), - }, + // { + // field: 'owner', + // headerName: 'Owner', + // renderHeader: () => <CustomColumnHeading headingTitle="Owner" />, + // width: 380, + // headerAlign: 'left', + // headerClassName: 'MuiDataGrid-header-override', + // renderCell: (params: GridRenderCellParams) => ( + // <Typography sx={cellStyles} data-testid="owner"> + // {params.value} + // </Typography> + // ), + // }, { field: 'identity_key', headerName: 'Identity Key', @@ -63,9 +65,12 @@ export const PageGateways: React.FC = () => { width: 380, headerAlign: 'left', renderCell: (params: GridRenderCellParams) => ( - <Typography sx={cellStyles} data-testid="identity-key"> + // <Typography sx={cellStyles} data-testid="identity-key"> + // {params.value} + // </Typography> + <MuiLink component={RRDLink} to={`/network-components/gateway/${params.row.identity_key}`}> {params.value} - </Typography> + </MuiLink> ), }, { @@ -81,6 +86,32 @@ export const PageGateways: React.FC = () => { </Typography> ), }, + { + field: 'routing_score', + headerName: 'Routing Score', + renderHeader: () => <CustomColumnHeading headingTitle="Routing Score" />, + headerClassName: 'MuiDataGrid-header-override', + width: 200, + headerAlign: 'left', + renderCell: (params: GridRenderCellParams) => ( + <Typography sx={cellStyles} data-testid="routing-score"> + {params.value} + </Typography> + ), + }, + { + field: 'avg_score', + headerName: 'Avg. Score', + renderHeader: () => <CustomColumnHeading headingTitle="Avg. Score" />, + headerClassName: 'MuiDataGrid-header-override', + width: 200, + headerAlign: 'left', + renderCell: (params: GridRenderCellParams) => ( + <Typography sx={cellStyles} data-testid="avg-score"> + {params.value} + </Typography> + ), + }, { field: 'host', renderHeader: () => <CustomColumnHeading headingTitle="IP:Port" />, diff --git a/explorer/src/routes/network-components.tsx b/explorer/src/routes/network-components.tsx index 73b17d108a..6bba379448 100644 --- a/explorer/src/routes/network-components.tsx +++ b/explorer/src/routes/network-components.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Routes as ReactRouterRoutes, Route, useNavigate } from 'react-router-dom'; import { BIG_DIPPER } from '../api/constants'; import { PageGateways } from '../pages/Gateways'; +import { PageGatewayDetail } from '../pages/GateDetail'; import { PageMixnodeDetail } from '../pages/MixnodeDetail'; import { PageMixnodes } from '../pages/Mixnodes'; @@ -18,6 +19,7 @@ export const NetworkComponentsRoutes: React.FC = () => ( <Route path="mixnodes" element={<PageMixnodes />} /> <Route path="mixnode/:id" element={<PageMixnodeDetail />} /> <Route path="gateways" element={<PageGateways />} /> + <Route path="gateway/:id" element={<PageGatewayDetail />} /> <Route path="validators" element={<ValidatorRoute />} /> <Route path="gateways/:id" element={<h1> Specific Gateways ID</h1>} /> </ReactRouterRoutes> diff --git a/explorer/src/typeDefs/explorer-api.ts b/explorer/src/typeDefs/explorer-api.ts index dd342ac149..f36aa2110f 100644 --- a/explorer/src/typeDefs/explorer-api.ts +++ b/explorer/src/typeDefs/explorer-api.ts @@ -1,5 +1,7 @@ /* eslint-disable camelcase */ +import { UseButtonParameters } from "@mui/base"; + export interface ClientConfig { url: string; version: string; @@ -126,12 +128,9 @@ export type GatewayResponse = GatewayResponseItem[]; export interface GatewayReportResponse { identity: string; owner: string; - most_recent_ipv4: boolean; - most_recent_ipv6: boolean; - last_hour_ipv4: number; - last_hour_ipv6: number; - last_day_ipv4: number; - last_day_ipv6: number; + most_recent: number; + last_hour: number; + last_day: number; } export type GatewayHistoryResponse = StatsResponse; @@ -202,6 +201,14 @@ export type StatusResponse = { }; }; +// export type UptimeReportResponse = { +// identity: string; +// owner: string; +// most_recent: number; +// last_hour: number; +// last_day: UseButtonParameters; +// }; + export type UptimeTime = { date: string; uptime: number;