Compare commits

...

12 Commits

Author SHA1 Message Date
Gala 0a9978b81e fix build a rever console utility addition 2022-10-27 12:37:18 +02:00
Gala 7cdfdf8437 requested changes 2022-10-27 11:05:19 +02:00
Gala fca32424b4 fix build 2022-10-24 12:01:37 +02:00
Gala 7ad9c15c9e PR requested changes 2022-10-24 11:51:46 +02:00
Gala 54716c7c09 fix build 2022-10-21 10:55:45 +02:00
Gala 2a0b1a3734 adding correct toolpit text and cleaning 2022-10-20 17:53:17 +02:00
Gala 7f35b3660b remove gateway name and desc 2022-10-20 17:02:17 +02:00
Gala 8470288079 fixing gateways pagination 2022-10-20 15:33:31 +02:00
Gala d5a65841af adding link style 2022-10-20 15:27:55 +02:00
Gala 76144928a0 adding loading state for gateways 2022-10-20 15:19:27 +02:00
Gala d6ffdb86ce adding uptime chart 2022-10-20 15:11:38 +02:00
Gala 74f9205be1 create gateway details page 2022-10-20 15:04:36 +02:00
12 changed files with 400 additions and 123 deletions
+1
View File
@@ -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.";
+9 -1
View File
@@ -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,12 @@ export class Api {
return res.json();
};
static fetchGatewayUptimeStoryById = async (id: string): Promise<UptimeStoryResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/history`)).json();
static fetchGatewayReportById = async (id: string): Promise<GatewayReportResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/report`)).json();
static fetchValidators = async (): Promise<ValidatorsResponse> => {
const res = await fetch(VALIDATORS_API);
const json = await res.json();
+57 -36
View File
@@ -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 { GatewayEnrichedRowType } 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) => (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, flex }) => (
<TableCell key={field} sx={{ fontSize: 14, fontWeight: 600, flex }}>
{title}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((eachRow) => (
<TableRow key={eachRow.id} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
{columnsData?.map((_, index) => (
<TableCell
key={_.title}
component="th"
scope="row"
variant="body"
sx={{
...cellStyles,
padding: 2,
width: 200,
fontSize: 14,
}}
data-testid={`${_.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(eachRow[columnsData[index].field], columnsData[index].field)}
rows: MixnodeRowType[] | GatewayEnrichedRowType[];
}> = ({ tableName, columnsData, rows }: UniversalTableProps) => {
const theme = useTheme();
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, flex, tooltipInfo }) => (
<TableCell key={field} sx={{ fontSize: 14, fontWeight: 600, flex }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{tooltipInfo && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip
title={tooltipInfo}
id={field}
placement="top-start"
textColor={theme.palette.nym.networkExplorer.tooltip.color}
bgColor={theme.palette.nym.networkExplorer.tooltip.background}
maxWidth={230}
arrow
/>
</Box>
)}
{title}
</Box>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
</TableHead>
<TableBody>
{rows.map((eachRow) => (
<TableRow key={eachRow.id} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
{columnsData?.map((data, index) => (
<TableCell
key={data.title}
component="th"
scope="row"
variant="body"
sx={{
...cellStyles,
padding: 2,
width: 200,
fontSize: 14,
}}
data-testid={`${data.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(eachRow[columnsData[index].field], columnsData[index].field)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
+28 -3
View File
@@ -1,23 +1,48 @@
import { GatewayResponse } from '../typeDefs/explorer-api';
import { GatewayResponse, GatewayResponseItem, GatewayReportResponse } from '../typeDefs/explorer-api';
export type GatewayRowType = {
id: string;
owner: string;
identity_key: string;
identityKey: string;
bond: number;
host: string;
location: string;
};
export type GatewayEnrichedRowType = GatewayRowType & {
routingScore: string;
avgUptime: string;
clientsPort: number;
mixPort: number;
};
export function gatewayToGridRow(arrayOfGateways: GatewayResponse): GatewayRowType[] {
return !arrayOfGateways
? []
: arrayOfGateways.map((gw) => ({
id: gw.owner,
owner: gw.owner,
identity_key: gw.gateway.identity_key || '',
identityKey: 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,
): GatewayEnrichedRowType {
return {
id: gateway.owner,
owner: gateway.owner,
identityKey: gateway.gateway.identity_key || '',
location: gateway?.gateway?.location || '',
bond: gateway.pledge_amount.amount || 0,
host: gateway.gateway.host || '',
clientsPort: gateway.gateway.clients_port || 0,
mixPort: gateway.gateway.mix_port || 0,
routingScore: `${report.most_recent}%`,
avgUptime: `${report.last_day || report.last_hour}%`,
};
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react';
import { ApiState, GatewayReportResponse, UptimeStoryResponse } from '../typeDefs/explorer-api';
import { Api } from '../api';
import { useApiState } from './hooks';
/**
* This context provides the state for a single gateway by identity key.
*/
interface GatewayState {
uptimeReport?: ApiState<GatewayReportResponse>;
uptimeStory?: ApiState<UptimeStoryResponse>;
}
export const GatewayContext = React.createContext<GatewayState>({});
export const useGatewayContext = (): React.ContextType<typeof GatewayContext> =>
React.useContext<GatewayState>(GatewayContext);
/**
* Provides a state context for a gateway by identity
* @param gatewayIdentityKey The identity key of the gateway
*/
export const GatewayContextProvider = ({
gatewayIdentityKey,
children,
}: {
gatewayIdentityKey: string;
children: JSX.Element;
}) => {
const [uptimeReport, fetchUptimeReportById, clearUptimeReportById] = useApiState<GatewayReportResponse>(
gatewayIdentityKey,
Api.fetchGatewayReportById,
'Failed to fetch gateway uptime report by id',
);
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] = useApiState<UptimeStoryResponse>(
gatewayIdentityKey,
Api.fetchGatewayUptimeStoryById,
'Failed to fetch gateway uptime history',
);
React.useEffect(() => {
// when the identity key changes, remove all previous data
clearUptimeReportById();
clearUptimeHistory();
Promise.all([fetchUptimeReportById(), fetchUptimeHistory()]);
}, [gatewayIdentityKey]);
const state = React.useMemo<GatewayState>(
() => ({
uptimeReport,
uptimeStory,
}),
[uptimeReport, uptimeStory],
);
return <GatewayContext.Provider value={state}>{children}</GatewayContext.Provider>;
};
+32 -15
View File
@@ -1,30 +1,47 @@
import * as React from 'react';
import { ApiState } from '../typeDefs/explorer-api';
type WrappedApiFn<T> = () => Promise<ApiState<T>>;
/**
* 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
*/
export const useApiState = <T>(
// eslint-disable-next-line @typescript-eslint/ban-types
fn: Function,
id: string,
fn: (argId: string) => Promise<T>,
errorMessage: string,
): [ApiState<T>, WrappedApiFn<T>] => {
): [ApiState<T> | undefined, () => Promise<ApiState<T>>, () => void] => {
// stores the state
const [value, setValue] = React.useState<ApiState<T>>();
const wrappedFn = React.useCallback(async () => {
// clear the value
const clearValueFn = () => setValue(undefined);
// this provides a method to trigger the delegate to fetch data
const wrappedFetchFn = React.useCallback(async () => {
setValue({ isLoading: true });
try {
// keep previous state and set to loading
setValue((prevState) => ({ ...prevState, isLoading: true }));
const data = await fn();
setValue({
// delegate to user function to get data and set if successful
const data = await fn(id);
const newValue: ApiState<T> = {
isLoading: false,
data,
});
return data;
};
setValue(newValue);
return newValue;
} catch (error) {
setValue({
// return the caught error or create a new error with the static error message
const newValue: ApiState<T> = {
error: error instanceof Error ? error : new Error(errorMessage),
isLoading: false,
});
return undefined;
};
setValue(newValue);
return newValue;
}
}, [setValue, fn]);
return [value || { isLoading: true }, wrappedFn];
}, [setValue, fn, id, errorMessage]);
return [value, wrappedFetchFn, clearValueFn];
};
+1
View File
@@ -109,6 +109,7 @@ export const MainContextProvider: React.FC = ({ children }) => {
};
const fetchGateways = async () => {
setGateways((d) => ({ ...d, isLoading: true }));
try {
const data = await Api.fetchGateways();
setGateways({ data, isLoading: false });
+1 -44
View File
@@ -11,6 +11,7 @@ import {
UptimeStoryResponse,
} from '../typeDefs/explorer-api';
import { Api } from '../api';
import { useApiState } from './hooks';
import { mixNodeResponseItemToMixnodeRowType, MixnodeRowType } from '../components/MixNodes';
/**
@@ -153,47 +154,3 @@ export const MixnodeContextProvider: React.FC<MixnodeContextProviderProps> = ({
return <MixnodeContext.Provider value={state}>{children}</MixnodeContext.Provider>;
};
/**
* 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<T>(
id: string,
fn: (argId: string) => Promise<T>,
errorMessage: string,
): [ApiState<T> | undefined, () => Promise<ApiState<T>>, () => void] {
// stores the state
const [value, setValue] = React.useState<ApiState<T> | 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<T> = {
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<T> = {
error: error instanceof Error ? error : new Error(errorMessage),
isLoading: false,
};
setValue(newValue);
return newValue;
}
}, [setValue, fn]);
return [value || { isLoading: true }, wrappedFetchFn, clearValueFn];
}
+179
View File
@@ -0,0 +1,179 @@
import * as React from 'react';
import { Alert, AlertTitle, Box, CircularProgress, Grid } from '@mui/material';
import { useParams } from 'react-router-dom';
import { GatewayResponseItem } from '../../typeDefs/explorer-api';
import { ColumnsType, DetailTable } from '../../components/DetailTable';
import { gatewayEnrichedToGridRow, GatewayEnrichedRowType } from '../../components/Gateways';
import { ComponentError } from '../../components/ComponentError';
import { ContentCard } from '../../components/ContentCard';
import { TwoColSmallTable } from '../../components/TwoColSmallTable';
import { UptimeChart } from '../../components/UptimeChart';
import { GatewayContextProvider, useGatewayContext } from '../../context/gateway';
import { useMainContext } from '../../context/main';
import { Title } from '../../components/Title';
const columns: ColumnsType[] = [
{
field: 'identityKey',
title: 'Identity Key',
headerAlign: 'left',
width: 230,
},
{
field: 'bond',
title: 'Bond',
flex: 1,
headerAlign: 'left',
},
{
field: 'routingScore',
title: 'Routing Score',
flex: 1,
headerAlign: 'left',
tooltipInfo:
'Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test.',
},
{
field: 'avgUptime',
title: 'Avg. Score',
flex: 1,
headerAlign: 'left',
tooltipInfo: 'Is the average routing score in the last 24 hours',
},
{
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 = ({ selectedGateway }: { selectedGateway: GatewayResponseItem | undefined }) => {
const [enrichGateway, setEnrichGateway] = React.useState<GatewayEnrichedRowType>();
const [status, setStatus] = React.useState<number[] | undefined>();
const { uptimeReport, uptimeStory } = useGatewayContext();
React.useEffect(() => {
if (uptimeReport?.data && selectedGateway) {
setEnrichGateway(gatewayEnrichedToGridRow(selectedGateway, uptimeReport.data));
}
}, [uptimeReport, selectedGateway]);
React.useEffect(() => {
if (enrichGateway) {
setStatus([enrichGateway.mixPort, enrichGateway.clientsPort]);
}
}, [enrichGateway]);
return (
<Box component="main">
<Title text="Gateway Detail" />
<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)}
/>
</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, id]);
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>
);
};
+28 -18
View File
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Button, Card, Grid, Typography } from '@mui/material';
import { Link as RRDLink } 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';
@@ -43,29 +44,20 @@ export const PageGateways: React.FC = () => {
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: 'identity_key',
field: 'identityKey',
headerName: 'Identity Key',
renderHeader: () => <CustomColumnHeading headingTitle="Identity Key" />,
headerClassName: 'MuiDataGrid-header-override',
width: 380,
headerAlign: 'left',
renderCell: (params: GridRenderCellParams) => (
<Typography sx={cellStyles} data-testid="identity-key">
<MuiLink
sx={{ ...cellStyles }}
component={RRDLink}
to={`/network-components/gateway/${params.row.identityKey}`}
>
{params.value}
</Typography>
</MuiLink>
),
},
{
@@ -109,6 +101,19 @@ export const PageGateways: React.FC = () => {
</Button>
),
},
{
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>
),
},
];
const handlePageSize = (event: SelectChangeEvent<string>) => {
@@ -133,7 +138,12 @@ export const PageGateways: React.FC = () => {
pageSize={pageSize}
searchTerm={searchTerm}
/>
<UniversalDataGrid rows={gatewayToGridRow(filteredGateways)} columns={columns} pageSize={pageSize} />
<UniversalDataGrid
pagination
rows={gatewayToGridRow(filteredGateways)}
columns={columns}
pageSize={pageSize}
/>
</Card>
</Grid>
</Grid>
@@ -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/GatewayDetail';
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>
+3 -6
View File
@@ -126,12 +126,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;