diff --git a/protocol-dashboard/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx b/protocol-dashboard/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx new file mode 100644 index 00000000000..cf2ef473b33 --- /dev/null +++ b/protocol-dashboard/src/components/IndividualServiceApiCallsChart/IndividualServiceApiCallsChart.tsx @@ -0,0 +1,42 @@ +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(a.timestamp).getTime() / 1000) ?? null + data = apiCalls?.map(a => a.total_count) ?? null + } + return ( + setBucket(option as Bucket)} + showLeadingDay + /> + ) +} + +export default IndividualServiceApiCallsChart diff --git a/protocol-dashboard/src/components/IndividualServiceApiCallsChart/index.tsx b/protocol-dashboard/src/components/IndividualServiceApiCallsChart/index.tsx new file mode 100644 index 00000000000..e36da82bddd --- /dev/null +++ b/protocol-dashboard/src/components/IndividualServiceApiCallsChart/index.tsx @@ -0,0 +1 @@ +export { default } from './IndividualServiceApiCallsChart' diff --git a/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx b/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx new file mode 100644 index 00000000000..94b2a438556 --- /dev/null +++ b/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/IndividualServiceUniqueUsersChart.tsx @@ -0,0 +1,42 @@ +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(a.timestamp).getTime() / 1000) ?? null + data = apiCalls?.map(a => a.unique_count) ?? null + } + return ( + setBucket(option as Bucket)} + showLeadingDay + /> + ) +} + +export default IndividualServiceUniqueUsersChart diff --git a/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/index.tsx b/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/index.tsx new file mode 100644 index 00000000000..cf51d38aa75 --- /dev/null +++ b/protocol-dashboard/src/components/IndividualServiceUniqueUsersChart/index.tsx @@ -0,0 +1 @@ +export { default } from './IndividualServiceUniqueUsersChart' diff --git a/protocol-dashboard/src/components/NodeOverview/NodeOverview.module.css b/protocol-dashboard/src/components/NodeOverview/NodeOverview.module.css index 2eaa948896e..075ef366bea 100644 --- a/protocol-dashboard/src/components/NodeOverview/NodeOverview.module.css +++ b/protocol-dashboard/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/protocol-dashboard/src/components/NodeOverview/NodeOverview.tsx b/protocol-dashboard/src/components/NodeOverview/NodeOverview.tsx index 2af57935e7a..1536b7d76f5 100644 --- a/protocol-dashboard/src/components/NodeOverview/NodeOverview.tsx +++ b/protocol-dashboard/src/components/NodeOverview/NodeOverview.tsx @@ -80,6 +80,7 @@ type NodeOverviewProps = { isOwner?: boolean isDeregistered?: boolean isUnregistered?: boolean + isLoading: boolean } const NodeOverview = ({ @@ -91,7 +92,8 @@ const NodeOverview = ({ delegateOwnerWallet, isOwner, isDeregistered, - isUnregistered + isUnregistered, + isLoading }: NodeOverviewProps) => { const { isOpen, onClick, onClose } = useModalControls() const { health, status, error } = useNodeHealth(endpoint, serviceType) @@ -252,76 +254,82 @@ const NodeOverview = ({ return ( -
-
- {serviceType === ServiceType.DiscoveryProvider - ? messages.dp - : messages.cn} -
- {isDeregistered && ( -
{messages.deregistered}
- )} - {!isDeregistered && ( -
- {`${messages.version} ${health?.version || version || 'unknown'}`} + {isLoading ? ( + + ) : ( + <> +
+
+ {serviceType === ServiceType.DiscoveryProvider + ? messages.dp + : messages.cn} +
+ {isDeregistered && ( +
{messages.deregistered}
+ )} + {!isDeregistered && ( +
+ {`${messages.version} ${health?.version || version || 'unknown'}`} +
+ )} + {!isDeregistered && isUnregistered && ( + <> +
- )} - {!isDeregistered && isUnregistered && ( - <> -
- - {(operatorWallet || health?.operatorWallet) && ( - - )} - {(delegateOwnerWallet || health?.delegateOwnerWallet) && ( - + )} + {healthDetails} + )} - {healthDetails} ) } diff --git a/protocol-dashboard/src/containers/Node/Node.module.css b/protocol-dashboard/src/containers/Node/Node.module.css index 63695afca69..76b1509681b 100644 --- a/protocol-dashboard/src/containers/Node/Node.module.css +++ b/protocol-dashboard/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; +} diff --git a/protocol-dashboard/src/containers/Node/Node.tsx b/protocol-dashboard/src/containers/Node/Node.tsx index 3985c574af7..d7c1edbe9d7 100644 --- a/protocol-dashboard/src/containers/Node/Node.tsx +++ b/protocol-dashboard/src/containers/Node/Node.tsx @@ -14,6 +14,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', @@ -22,59 +25,72 @@ const messages = { } type ContentNodeProps = { spID: number; accountWallet: Address | undefined } -const ContentNode = ({ spID, accountWallet }: ContentNodeProps) => { +const ContentNode: React.FC = ({ + spID, + accountWallet +}: ContentNodeProps) => { const { node: contentNode, status } = useContentNode({ spID }) 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 = ({ spID, accountWallet }: DiscoveryProviderProps) => { - const { node: discoveryProvider, status } = useDiscoveryProvider({ spID }) +const DiscoveryNode: React.FC = ({ + spID, + accountWallet +}: 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} + ) } @@ -92,7 +108,7 @@ const Node = () => { defaultPreviousPageRoute={SERVICES} > {isDiscovery ? ( - + ) : ( )} diff --git a/protocol-dashboard/src/store/cache/analytics/hooks.ts b/protocol-dashboard/src/store/cache/analytics/hooks.ts index 2abfea97029..4d519d65984 100644 --- a/protocol-dashboard/src/store/cache/analytics/hooks.ts +++ b/protocol-dashboard/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' import { AnyAction } from '@reduxjs/toolkit' dayjs.extend(duration) @@ -118,6 +119,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 --------------------------------- @@ -151,20 +156,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 + let endpoint = `${node}/v1/metrics/${route}?bucket_size=${bucketSize}&start_time=${startTime}` + if (route === 'routes') { + endpoint = `${node}/v1/metrics/routes/${bucket}?bucket_size=${bucketSize}` + } + if (node) { + data = (await fetchWithTimeout(endpoint)).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) @@ -192,6 +215,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, @@ -404,6 +437,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/protocol-dashboard/src/store/cache/analytics/slice.ts b/protocol-dashboard/src/store/cache/analytics/slice.ts index 3c2bdfa27fb..b0405122279 100644 --- a/protocol-dashboard/src/store/cache/analytics/slice.ts +++ b/protocol-dashboard/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