From ccc078eac98cc85a9259a86f1f94d7dc8f528717 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 8 Feb 2022 17:49:15 -0800 Subject: [PATCH] Add per-service charts --- .../IndividualServiceApiCallsChart.tsx | 45 +++++++ .../IndividualServiceApiCallsChart/index.tsx | 1 + .../IndividualServiceUniqueUsersChart.tsx | 45 +++++++ .../index.tsx | 1 + .../NodeOverview/NodeOverview.module.css | 7 ++ src/components/NodeOverview/NodeOverview.tsx | 111 +++++++++++------- src/containers/Node/Node.module.css | 17 ++- src/containers/Node/Node.tsx | 64 +++++----- src/store/cache/analytics/hooks.ts | 89 ++++++++++---- src/store/cache/analytics/slice.ts | 25 +++- 10 files changed, 310 insertions(+), 95 deletions(-) create mode 100644 src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx create mode 100644 src/components/IndividualServiceApiCallsChart/index.tsx create mode 100644 src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx create mode 100644 src/components/IndividualServiceUniqueUsersChart/index.tsx 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 c216935..ecb83f9 100644 --- a/src/store/cache/analytics/hooks.ts +++ b/src/store/cache/analytics/hooks.ts @@ -16,7 +16,8 @@ import { CountRecord, setTopApps, setTrailingTopGenres, - MetricError + MetricError, + setIndividualServiceApiCalls } from './slice' import { useEffect, useState } from 'react' import { DiscoveryProvider } from 'types' @@ -24,7 +25,7 @@ import { useDiscoveryProviders } from '../discoveryProvider/hooks' 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() @@ -119,13 +120,14 @@ 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 --------------------------------- -async function fetchRoutesTimeSeries( - bucket: Bucket, - nodes: DiscoveryProvider[] -) { +async function fetchRoutesTimeSeries(bucket: Bucket) { let error = false let metric: TimeSeriesRecord[] = [] try { @@ -146,30 +148,46 @@ async function fetchRoutesTimeSeries( } export function fetchApiCalls( - bucket: Bucket, - nodes: DiscoveryProvider[] + bucket: Bucket ): ThunkAction> { return async dispatch => { - const metric = await fetchRoutesTimeSeries(bucket, nodes) + const metric = await fetchRoutesTimeSeries(bucket) dispatch(setApiCalls({ metric, bucket })) } } +/** + * 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, - nodes: DiscoveryProvider[], - 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 } - }) + 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) @@ -183,11 +201,10 @@ async function fetchTimeSeries( } export function fetchPlays( - bucket: Bucket, - nodes: DiscoveryProvider[] + bucket: Bucket ): ThunkAction> { return async dispatch => { - let metric = await fetchTimeSeries('plays', bucket, nodes, true) + let metric = await fetchTimeSeries('plays', bucket, true) if (metric !== MetricError.ERROR) { metric = metric.filter( m => m.timestamp !== '1620345600' && m.timestamp !== '1620259200' @@ -197,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, @@ -399,7 +426,7 @@ export const useApiCalls = (bucket: Bucket) => { (apiCalls === null || apiCalls === undefined) ) { setDoOnce(bucket) - dispatch(fetchApiCalls(bucket, nodes)) + dispatch(fetchApiCalls(bucket)) } }, [dispatch, apiCalls, bucket, nodes, doOnce]) @@ -412,6 +439,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 => @@ -460,7 +509,7 @@ export const usePlays = (bucket: Bucket) => { (plays === null || plays === undefined) ) { setDoOnce(bucket) - dispatch(fetchPlays(bucket, nodes)) + dispatch(fetchPlays(bucket)) } }, [dispatch, plays, bucket, nodes, doOnce]) 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