Skip to content

Commit 3d962d8

Browse files
Merge pull request #88 from ibi-group/daily-stats
Daily Stats
2 parents 31afc3b + fc17a84 commit 3d962d8

File tree

11 files changed

+287
-85
lines changed

11 files changed

+287
-85
lines changed

.github/workflows/e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ jobs:
1212

1313
steps:
1414
- uses: actions/checkout@v2
15-
- name: Use Node.js 18.x
15+
- name: Use Node.js 22.x
1616
uses: actions/setup-node@v1
1717
with:
18-
node-version: 18.x
18+
node-version: 22.x
1919
- name: Install npm packages using cache
2020
uses: bahmutov/npm-install@v1
2121
with:

.github/workflows/node-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ jobs:
1212

1313
steps:
1414
- uses: actions/checkout@v2
15-
- name: Use Node.js 18.x
15+
- name: Use Node.js 22.x
1616
uses: actions/setup-node@v1
1717
with:
18-
node-version: 18.x
18+
node-version: 22.x
1919
- name: Install npm packages using cache
2020
uses: bahmutov/npm-install@v1
2121
with:

__tests__/e2e/e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe('end-to-end tests', () => {
199199
downloadPath: '/tmp'
200200
})
201201

202-
await expect(page).toClick('div', { text: uploadString })
202+
await expect(page).toClick('button', { text: uploadString })
203203

204204
await waitForDownload('anon-trip-data')
205205
await expect(page).toMatch('You last downloaded', { timeout: 6000 })

components/AdminUserDashboard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import CDPUserDashboard from './CDPUserDashboard'
1212
import ErrorEventsDashboard from './ErrorEventsDashboard'
1313
import RequestLogsDashboard from './RequestLogsDashboard'
1414
import UserList from './UserList'
15+
import DailyStatsDashboard from './DailyStatsDashboard'
1516

1617
export default function AdminUserDashboard(): JSX.Element {
1718
const {
@@ -39,6 +40,9 @@ export default function AdminUserDashboard(): JSX.Element {
3940
</div>
4041
{hasApiManager && <RequestLogsDashboard isAdmin summaryView />}
4142
</Tab>
43+
<Tab eventKey="dailystats" title="Daily Stats">
44+
<DailyStatsDashboard />
45+
</Tab>
4246
<Tab eventKey="errors" title="Errors">
4347
<ErrorEventsDashboard />
4448
</Tab>

components/ApiKeyUsageChart.tsx

Lines changed: 16 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,10 @@ import { Key } from '@styled-icons/fa-solid/Key'
44
import clone from 'clone'
55
import moment from 'moment'
66
import { Button } from 'react-bootstrap'
7-
import {
8-
XYPlot,
9-
XAxis,
10-
YAxis,
11-
VerticalGridLines,
12-
HorizontalGridLines,
13-
VerticalRectSeries,
14-
RectSeriesPoint,
15-
RVValueEventHandler
16-
} from 'react-vis'
177

18-
import { GraphValue, Requests, Plan } from '../types/graph'
8+
import { Requests, Plan, GraphNumberValue } from '../types/graph'
9+
10+
import Chart from './Chart'
1911

2012
export type Props = {
2113
aggregatedView?: boolean
@@ -27,18 +19,7 @@ export type Props = {
2719
* Renders a chart showing API Key usage (requests over time) for a particular
2820
* API key.
2921
*/
30-
class ApiKeyUsageChart extends Component<Props, { value: GraphValue | null }> {
31-
constructor(props: Props) {
32-
super(props)
33-
this.state = {
34-
value: null
35-
}
36-
}
37-
38-
handleClearValue = () => {
39-
this.setState({ value: null })
40-
}
41-
22+
class ApiKeyUsageChart extends Component<Props> {
4223
_renderChartTitle = () => {
4324
const { aggregatedView, isAdmin } = this.props
4425
// Do not show chart title for non-admin users.
@@ -140,12 +121,6 @@ class ApiKeyUsageChart extends Component<Props, { value: GraphValue | null }> {
140121
? null
141122
: (this.props.id && this.props?.plan?.apiUsers?.[this.props.id]) || null
142123

143-
handleSetValue: RVValueEventHandler<RectSeriesPoint> = (
144-
data: RectSeriesPoint
145-
) => {
146-
this.setState({ value: data })
147-
}
148-
149124
handleViewApiKey = () => {
150125
const { id, plan } = this.props
151126
if (!id || !plan) return
@@ -164,8 +139,6 @@ class ApiKeyUsageChart extends Component<Props, { value: GraphValue | null }> {
164139

165140
render() {
166141
const { aggregatedView, id, plan } = this.props
167-
const { value } = this.state
168-
const ONE_DAY_MILLIS = 86400000
169142
const startDate = moment(plan?.result.startDate)
170143
if (!aggregatedView && !id) {
171144
console.warn('Cannot show non-aggregated view if id prop is undefined.')
@@ -174,55 +147,24 @@ class ApiKeyUsageChart extends Component<Props, { value: GraphValue | null }> {
174147
// Render the # of requests per API key on each day beginning
175148
// with the start date.
176149
const requestData = this._getRequestData()
177-
const timestamp = startDate.valueOf()
178-
let rangeMax = 0
179150
// Format request data for chart component.
180-
const CHART_DATA: RectSeriesPoint[] =
151+
const CHART_DATA: GraphNumberValue[] =
181152
requestData?.map((value, i) => {
182153
if (i > 0) startDate.add(1, 'days')
183-
const begin = startDate.valueOf()
184154
// @ts-ignore TYPESCRIPT TODO: what is going on here?
185-
const y = value[0]
186-
const end = begin + ONE_DAY_MILLIS
187-
if (y > rangeMax) rangeMax = y
188-
return { x: end, x0: begin, y, y0: 0 }
155+
return { x: startDate.valueOf(), y: value[0] }
189156
}) || []
190-
const maxY = rangeMax === 0 ? 10 : Math.ceil(rangeMax / 10) * 10
191157
return (
192-
<div className="usage-list" style={{ display: 'inline-block' }}>
193-
{this._renderChartTitle()}
194-
{this._renderKeyInfo()}
195-
<XYPlot
196-
height={300}
197-
style={{ overflow: 'initial' }}
198-
width={600} // Round up max y value to the nearest 10
199-
xDomain={[
200-
timestamp - 2 * ONE_DAY_MILLIS,
201-
timestamp + 30 * ONE_DAY_MILLIS
202-
]}
203-
yDomain={[0, maxY]}
204-
>
205-
<VerticalGridLines />
206-
<HorizontalGridLines />
207-
<XAxis tickFormat={(d) => moment(d).format('MMM DD')} />
208-
<YAxis />
209-
<VerticalRectSeries
210-
data={CHART_DATA}
211-
onValueMouseOut={this.handleClearValue} // Update value on mouse over/out.
212-
onValueMouseOver={this.handleSetValue}
213-
style={{ stroke: '#fff' }}
214-
/>
215-
</XYPlot>
216-
<p style={{ textAlign: 'center' }}>
217-
{value ? (
218-
<>
219-
{moment(value.x).format('MMM DD')}: {value.y} requests
220-
</>
221-
) : (
222-
<>[Hover over bars to see values.]</>
223-
)}
224-
</p>
225-
</div>
158+
<Chart
159+
data={CHART_DATA}
160+
entityType="requests"
161+
title={
162+
<>
163+
{this._renderChartTitle()}
164+
{this._renderKeyInfo()}
165+
</>
166+
}
167+
/>
226168
)
227169
}
228170
}

components/CDPUserDashboard.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ const CDPUserDashboard = (props: Props): JSX.Element => {
9797
const url = `${CDP_FILES_URL}`
9898

9999
const [swrData, setServerResponse] = useState<{
100-
// TODO: Shared type
101-
data?: any
100+
data?: {
101+
data: CDPFile[]
102+
}
102103
error?: string
103104
message?: string
104105
status?: string
@@ -151,9 +152,8 @@ const CDPUserDashboard = (props: Props): JSX.Element => {
151152
fetchData()
152153
}, [auth0, currentlyDownloadingFile])
153154

154-
let files = Object.keys(swrData).length > 0 ? swrData?.data?.data : []
155-
files = files
156-
?.filter((file: CDPFile) => file?.size > 0)
155+
const files = (swrData?.data?.data || [])
156+
.filter((file: CDPFile) => file?.size > 0)
157157
// Negative sorts "reverse alphabetically" which allows newest files to appear first
158158
.sort((a: CDPFile, b: CDPFile) => -a.key.localeCompare(b.key))
159159

components/Chart.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import React, { Component, ReactNode } from 'react'
2+
import moment from 'moment'
3+
import {
4+
XYPlot,
5+
XAxis,
6+
YAxis,
7+
VerticalGridLines,
8+
HorizontalGridLines,
9+
VerticalRectSeries,
10+
RectSeriesPoint,
11+
RVValueEventHandler
12+
} from 'react-vis'
13+
14+
import { GraphNumberValue, GraphValue } from '../types/graph'
15+
16+
export type Props = {
17+
data: GraphNumberValue[]
18+
entityType: string
19+
title: ReactNode
20+
}
21+
22+
type ChartBounds = {
23+
chartData: RectSeriesPoint[]
24+
endDateMillis: number
25+
rangeMax: number
26+
startDateMillis: number
27+
}
28+
29+
/**
30+
* Renders a chart with presets and default behaviors (e.g. hover over data to show value).
31+
*/
32+
class Chart extends Component<Props, { value: GraphValue | null }> {
33+
constructor(props: Props) {
34+
super(props)
35+
this.state = {
36+
value: null
37+
}
38+
}
39+
40+
handleClearValue = (): void => {
41+
this.setState({ value: null })
42+
}
43+
44+
/**
45+
* Extract the ranges of the data provided.
46+
*/
47+
_getBounds = (): ChartBounds => {
48+
const ONE_DAY_MILLIS = 86400000
49+
let rangeMax = 0
50+
let startDateMillis = Number.MAX_VALUE
51+
let endDateMillis = 0
52+
53+
const chartData = this.props.data.map(({ x, y }): RectSeriesPoint => {
54+
// End the x range one millisecond before so that the bar
55+
// doesn't bleed into the next day and the chart doesn't associate the bar with the next day.
56+
const end = x + ONE_DAY_MILLIS - 1
57+
if (y > rangeMax) rangeMax = y
58+
if (startDateMillis > x) startDateMillis = x
59+
if (endDateMillis < end) endDateMillis = end
60+
return { x: end, x0: x, y, y0: 0 }
61+
})
62+
63+
return {
64+
chartData,
65+
endDateMillis,
66+
rangeMax,
67+
startDateMillis
68+
}
69+
}
70+
71+
handleSetValue: RVValueEventHandler<RectSeriesPoint> = (
72+
data: RectSeriesPoint
73+
) => {
74+
this.setState({ value: data })
75+
}
76+
77+
render(): JSX.Element | null {
78+
const { data, entityType, title } = this.props
79+
if (!data.length) return null
80+
81+
const days = 30
82+
const { value } = this.state
83+
const ONE_DAY_MILLIS = 86400000
84+
85+
const { chartData, endDateMillis, rangeMax, startDateMillis } =
86+
this._getBounds()
87+
88+
const renderedTitle = typeof title === 'string' ? <h3>{title}</h3> : title
89+
90+
// Round up max y value to the nearest 10
91+
const maxY = rangeMax === 0 ? 10 : Math.ceil(rangeMax / 10) * 10
92+
return (
93+
<div className="usage-list" style={{ display: 'inline-block' }}>
94+
{renderedTitle}
95+
<XYPlot
96+
height={300}
97+
style={{ overflow: 'initial' }}
98+
width={600}
99+
xDomain={[
100+
startDateMillis,
101+
// Display at least 30 days if data spans over less than 30 days.
102+
Math.max(startDateMillis + days * ONE_DAY_MILLIS, endDateMillis)
103+
]}
104+
yDomain={[0, maxY]}
105+
>
106+
<VerticalGridLines />
107+
<HorizontalGridLines />
108+
<XAxis tickFormat={(d) => moment(d).format('MMM DD')} />
109+
<YAxis />
110+
<VerticalRectSeries
111+
data={chartData}
112+
onValueMouseOut={this.handleClearValue} // Update value on mouse over/out.
113+
onValueMouseOver={this.handleSetValue}
114+
style={{ stroke: '#fff' }}
115+
/>
116+
</XYPlot>
117+
<p style={{ textAlign: 'center' }}>
118+
{value ? (
119+
<>
120+
{moment(value.x).format('MMM DD')}: {value.y} {entityType}
121+
</>
122+
) : (
123+
<>[Hover over bars to see values.]</>
124+
)}
125+
</p>
126+
</div>
127+
)
128+
}
129+
}
130+
131+
export default Chart

components/DailyStatsChart.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React, { Component } from 'react'
2+
3+
import Chart from './Chart'
4+
import { GraphNumberValue } from '../types/graph'
5+
6+
export type StatsRecord = {
7+
date: number
8+
otpUsers?: number
9+
otpUsersWithTripRequests?: number
10+
tripRequests?: number
11+
}
12+
13+
export type Props = {
14+
entityType: string
15+
records: StatsRecord[]
16+
series: keyof StatsRecord
17+
}
18+
/**
19+
* Renders a chart showing API Key usage (requests over time) for a particular
20+
* API key.
21+
*/
22+
class DailyStatsChart extends Component<Props> {
23+
_getSeries = (series: keyof StatsRecord): GraphNumberValue[] =>
24+
(this.props.records || []).map((value) => ({
25+
x: value.date.valueOf(),
26+
y: value[series] || 0
27+
}))
28+
29+
render(): JSX.Element | null {
30+
const { entityType, records, series } = this.props
31+
if (!records.length) return null
32+
33+
// Render the given series on each day beginning with the start date.
34+
const data = this._getSeries(series)
35+
return <Chart data={data} entityType={entityType} title={entityType} />
36+
}
37+
}
38+
39+
export default DailyStatsChart

0 commit comments

Comments
 (0)