diff --git a/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx b/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx new file mode 100644 index 0000000..2c33fb8 --- /dev/null +++ b/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx @@ -0,0 +1,45 @@ +import LineChart from 'components/LineChart' +import React, { useState } from 'react' +import { useIndividualServiceApiCalls } from 'store/cache/analytics/hooks' +import { Bucket, MetricError } from 'store/cache/analytics/slice' + +type OwnProps = { + node: string +} + +type IndividualServiceApiCallsChartProps = OwnProps + +const IndividualServiceApiCallsChart: React.FC = ({ + node +}) => { + const [bucket, setBucket] = useState(Bucket.MONTH) + + const { apiCalls } = useIndividualServiceApiCalls(node, bucket) + let error, labels, data + if (apiCalls === MetricError.ERROR) { + error = true + labels = [] + data = [] + } else { + labels = + apiCalls?.map( + a => new Date(parseInt(a.timestamp, 10) * 1000).getTime() / 1000 + ) ?? null + data = apiCalls?.map(a => a.count) ?? null + } + return ( + setBucket(option as Bucket)} + showLeadingDay + /> + ) +} + +export default IndividualServiceApiCallsChart diff --git a/src/components/IndividualServiceApiCallsChart/index.tsx b/src/components/IndividualServiceApiCallsChart/index.tsx new file mode 100644 index 0000000..e36da82 --- /dev/null +++ b/src/components/IndividualServiceApiCallsChart/index.tsx @@ -0,0 +1 @@ +export { default } from './IndividualServiceApiCallsChart' diff --git a/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx b/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx new file mode 100644 index 0000000..b25dd7b --- /dev/null +++ b/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx @@ -0,0 +1,45 @@ +import LineChart from 'components/LineChart' +import React, { useState } from 'react' +import { useIndividualServiceApiCalls } from 'store/cache/analytics/hooks' +import { Bucket, MetricError } from 'store/cache/analytics/slice' + +type OwnProps = { + node: string +} + +type IndividualServiceUniqueUsersChartProps = OwnProps + +const IndividualServiceUniqueUsersChart: React.FC = ({ + node +}) => { + const [bucket, setBucket] = useState(Bucket.MONTH) + + const { apiCalls } = useIndividualServiceApiCalls(node, bucket) + let error, labels, data + if (apiCalls === MetricError.ERROR) { + error = true + labels = [] + data = [] + } else { + labels = + apiCalls?.map( + a => new Date(parseInt(a.timestamp, 10) * 1000).getTime() / 1000 + ) ?? null + data = apiCalls?.map(a => a.unique_count) ?? null + } + return ( + setBucket(option as Bucket)} + showLeadingDay + /> + ) +} + +export default IndividualServiceUniqueUsersChart diff --git a/src/components/IndividualServiceUniqueUsersChart/index.tsx b/src/components/IndividualServiceUniqueUsersChart/index.tsx new file mode 100644 index 0000000..cf51d38 --- /dev/null +++ b/src/components/IndividualServiceUniqueUsersChart/index.tsx @@ -0,0 +1 @@ +export { default } from './IndividualServiceUniqueUsersChart' diff --git a/src/components/NodeOverview/NodeOverview.module.css b/src/components/NodeOverview/NodeOverview.module.css index afd6e64..ea08f49 100644 --- a/src/components/NodeOverview/NodeOverview.module.css +++ b/src/components/NodeOverview/NodeOverview.module.css @@ -5,6 +5,13 @@ display: inline-flex; flex-direction: column; box-sizing: border-box; + min-height: 240px; +} + +.loading { + margin: 42px auto 0; + justify-content: center; + align-items: center; } .header { diff --git a/src/components/NodeOverview/NodeOverview.tsx b/src/components/NodeOverview/NodeOverview.tsx index 09c3608..c3cd68f 100644 --- a/src/components/NodeOverview/NodeOverview.tsx +++ b/src/components/NodeOverview/NodeOverview.tsx @@ -11,6 +11,7 @@ import { useModalControls } from 'utils/hooks' import desktopStyles from './NodeOverview.module.css' import mobileStyles from './NodeOverviewMobile.module.css' import { createStyles } from 'utils/mobile' +import Loading from 'components/Loading' const styles = createStyles({ desktopStyles, mobileStyles }) @@ -36,14 +37,15 @@ const ServiceDetail = ({ label, value }: { label: string; value: string }) => { } type NodeOverviewProps = { - spID: number - serviceType: ServiceType - version: string - endpoint: string - operatorWallet: Address - delegateOwnerWallet: Address - isOwner: boolean - isDeregistered: boolean + spID?: number + serviceType?: ServiceType + version?: string + endpoint?: string + operatorWallet?: Address + delegateOwnerWallet?: Address + isOwner?: boolean + isDeregistered?: boolean + isLoading: boolean } const NodeOverview = ({ @@ -54,50 +56,69 @@ const NodeOverview = ({ operatorWallet, delegateOwnerWallet, isOwner, - isDeregistered + isDeregistered, + isLoading }: NodeOverviewProps) => { const { isOpen, onClick, onClose } = useModalControls() return ( -
-
- {serviceType === ServiceType.DiscoveryProvider - ? messages.dp - : messages.cn} -
- {isDeregistered ? ( -
{messages.deregistered}
- ) : ( -
- {`${messages.version} ${version}`} + {isLoading ? ( + + ) : ( + <> +
+
+ {serviceType === ServiceType.DiscoveryProvider + ? messages.dp + : messages.cn} +
+ {isDeregistered ? ( +
{messages.deregistered}
+ ) : ( +
+ {`${messages.version} ${version || ''}`} +
+ )} + {isOwner && + !isDeregistered && + spID && + endpoint && + serviceType && + delegateOwnerWallet && ( + <> +
- )} - {isOwner && !isDeregistered && ( - <> -
- - - {delegateOwnerWallet && ( - + ) : null} + )} ) diff --git a/src/containers/Node/Node.module.css b/src/containers/Node/Node.module.css index 63695af..c97247a 100644 --- a/src/containers/Node/Node.module.css +++ b/src/containers/Node/Node.module.css @@ -1,4 +1,19 @@ .container { - display: inline-flex; width: 100%; } + +.section { + margin-bottom: 16px; + display: flex; + margin-left: -8px; + margin-right: -8px; +} + +.section > * { + width: 100%; + margin: 0 8px; +} + +.chart { + min-height: 340px; +} \ No newline at end of file diff --git a/src/containers/Node/Node.tsx b/src/containers/Node/Node.tsx index 029fb6b..b2925e7 100644 --- a/src/containers/Node/Node.tsx +++ b/src/containers/Node/Node.tsx @@ -16,6 +16,9 @@ import { SERVICES, NOT_FOUND } from 'utils/routes' +import IndividualServiceApiCallsChart from 'components/IndividualServiceApiCallsChart' +import clsx from 'clsx' +import IndividualServiceUniqueUsersChart from 'components/IndividualServiceUniqueUsersChart' const messages = { title: 'SERVICE', @@ -32,57 +35,64 @@ const ContentNode: React.FC = ({ if (status === Status.Failure) { return null - } else if (status === Status.Loading) { - return null } - // TODO: compare owner with the current user - const isOwner = accountWallet === contentNode!.owner + const isOwner = accountWallet === contentNode?.owner ?? false return ( ) } -type DiscoveryProviderProps = { +type DiscoveryNodeProps = { spID: number accountWallet: Address | undefined } -const DiscoveryProvider: React.FC = ({ +const DiscoveryNode: React.FC = ({ spID, accountWallet -}: DiscoveryProviderProps) => { - const { node: discoveryProvider, status } = useDiscoveryProvider({ spID }) +}: DiscoveryNodeProps) => { + const { node: discoveryNode, status } = useDiscoveryProvider({ spID }) const pushRoute = usePushRoute() if (status === Status.Failure) { pushRoute(NOT_FOUND) return null - } else if (status === Status.Loading) { - return null } - const isOwner = accountWallet === discoveryProvider!.owner + const isOwner = accountWallet === discoveryNode?.owner ?? false return ( - + <> +
+ +
+ {discoveryNode ? ( +
+ + +
+ ) : null} + ) } @@ -107,7 +117,7 @@ const Node: React.FC = (props: NodeProps) => { defaultPreviousPageRoute={SERVICES} > {isDiscovery ? ( - + ) : ( )} diff --git a/src/store/cache/analytics/hooks.ts b/src/store/cache/analytics/hooks.ts index 58de141..c980829 100644 --- a/src/store/cache/analytics/hooks.ts +++ b/src/store/cache/analytics/hooks.ts @@ -16,13 +16,14 @@ import { CountRecord, setTopApps, setTrailingTopGenres, - MetricError + MetricError, + setIndividualServiceApiCalls } from './slice' import { useEffect, useState } from 'react' import { useAverageBlockTime, useEthBlockNumber } from '../protocol/hooks' import { weiAudToAud } from 'utils/numeric' import { ELECTRONIC_SUB_GENRES } from './genres' -import { fetchWithLibs } from 'utils/fetch' +import { fetchWithLibs, fetchWithTimeout } from 'utils/fetch' dayjs.extend(duration) const MONTH_IN_MS = dayjs.duration({ months: 1 }).asMilliseconds() @@ -117,6 +118,10 @@ export const getTrailingTopGenres = ( : null export const getTopApps = (state: AppState, { bucket }: { bucket: Bucket }) => state.cache.analytics.topApps ? state.cache.analytics.topApps[bucket] : null +export const getIndividualServiceApiCalls = ( + state: AppState, + { node, bucket }: { node: string; bucket: Bucket } +) => state.cache.analytics.individualServiceApiCalls?.[node]?.[bucket] ?? null // -------------------------------- Thunk Actions --------------------------------- @@ -150,20 +155,38 @@ export function fetchApiCalls( } } +/** + * Fetches time series data from a discovery node + * @param route The route to fetch from (plays, routes) + * @param bucket The bucket size + * @param clampDays Whether or not to remove partial current day + * @param node An optional node to make the request against + * @returns the metric itself or a MetricError + */ async function fetchTimeSeries( route: string, bucket: Bucket, - clampDays: boolean = true + clampDays: boolean = true, + node?: string ) { const startTime = getStartTime(bucket, clampDays) let error = false let metric: TimeSeriesRecord[] = [] try { const bucketSize = BUCKET_GRANULARITY_MAP[bucket] - const data = (await fetchWithLibs({ - endpoint: `v1/metrics/${route}`, - queryParams: { bucket_size: bucketSize, start_time: startTime } - })) as any + let data + if (node) { + data = ( + await fetchWithTimeout( + `${node}/v1/metrics/${route}?bucket_size=${bucketSize}&start_time=${startTime}` + ) + ).data.slice(1) // Trim off the first day so we don't show partial data + } else { + data = await fetchWithLibs({ + endpoint: `v1/metrics/${route}`, + queryParams: { bucket_size: bucketSize, start_time: startTime } + }) + } metric = data.reverse() } catch (e) { console.error(e) @@ -191,6 +214,16 @@ export function fetchPlays( } } +export function fetchIndividualServiceRouteMetrics( + node: string, + bucket: Bucket +): ThunkAction> { + return async dispatch => { + const metric = await fetchTimeSeries('routes', bucket, true, node) + dispatch(setIndividualServiceApiCalls({ node, metric, bucket })) + } +} + export function fetchTotalStaked( bucket: Bucket, averageBlockTime: number, @@ -403,6 +436,28 @@ export const useApiCalls = (bucket: Bucket) => { return { apiCalls } } +export const useIndividualServiceApiCalls = (node: string, bucket: Bucket) => { + const [doOnce, setDoOnce] = useState(null) + const apiCalls = useSelector(state => + getIndividualServiceApiCalls(state as AppState, { node, bucket }) + ) + const dispatch = useDispatch() + useEffect(() => { + if (doOnce !== bucket && (apiCalls === null || apiCalls === undefined)) { + setDoOnce(bucket) + dispatch(fetchIndividualServiceRouteMetrics(node, bucket)) + } + }, [dispatch, apiCalls, bucket, node, doOnce]) + + useEffect(() => { + if (apiCalls) { + setDoOnce(null) + } + }, [apiCalls, setDoOnce]) + + return { apiCalls } +} + export const useTotalStaked = (bucket: Bucket) => { const [doOnce, setDoOnce] = useState(null) const totalStaked = useSelector(state => diff --git a/src/store/cache/analytics/slice.ts b/src/store/cache/analytics/slice.ts index 3c2bdfa..b040512 100644 --- a/src/store/cache/analytics/slice.ts +++ b/src/store/cache/analytics/slice.ts @@ -39,6 +39,10 @@ export type State = { topApps: CountMetric trailingTopGenres: CountMetric trailingApiCalls: CountMetric + individualServiceApiCalls: { + // Mapping of node endpoint to TimeSeriesMetric + [node: string]: TimeSeriesMetric + } } export const initialState: State = { @@ -47,7 +51,8 @@ export const initialState: State = { plays: {}, topApps: {}, trailingTopGenres: {}, - trailingApiCalls: {} + trailingApiCalls: {}, + individualServiceApiCalls: {} } type SetApiCalls = { metric: TimeSeriesRecord[] | MetricError; bucket: Bucket } @@ -62,6 +67,11 @@ type SetTrailingTopGenres = { bucket: Bucket } type SetTrailingApiCalls = { metric: CountRecord | MetricError; bucket: Bucket } +type SetIndividualServiceApiCalls = { + node: string + metric: TimeSeriesRecord[] | MetricError + bucket: Bucket +} const slice = createSlice({ name: 'analytics', @@ -96,6 +106,16 @@ const slice = createSlice({ ) => { const { metric, bucket } = action.payload state.trailingApiCalls[bucket] = metric + }, + setIndividualServiceApiCalls: ( + state, + action: PayloadAction + ) => { + const { node, metric, bucket } = action.payload + if (!state.individualServiceApiCalls[node]) { + state.individualServiceApiCalls[node] = {} + } + state.individualServiceApiCalls[node][bucket] = metric } } }) @@ -106,7 +126,8 @@ export const { setPlays, setTopApps, setTrailingTopGenres, - setTrailingApiCalls + setTrailingApiCalls, + setIndividualServiceApiCalls } = slice.actions export default slice.reducer