Skip to content

Commit 691fc72

Browse files
committed
Add basic frontend + minor backend changes
1 parent 3b55d4b commit 691fc72

File tree

13 files changed

+361
-16
lines changed

13 files changed

+361
-16
lines changed

coderd/metrics.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,36 @@ import (
2121

2222
const AgentStatIntervalEnv = "CODER_AGENT_STAT_INTERVAL"
2323

24+
func FillEmptyDAUDays(rows []database.GetDAUsFromAgentStatsRow) []database.GetDAUsFromAgentStatsRow {
25+
var newRows []database.GetDAUsFromAgentStatsRow
26+
27+
for i, row := range rows {
28+
if i == 0 {
29+
newRows = append(newRows, row)
30+
continue
31+
}
32+
33+
last := rows[i-1]
34+
35+
const day = time.Hour * 24
36+
diff := row.Date.Sub(last.Date)
37+
for diff > day {
38+
if diff <= day {
39+
break
40+
}
41+
last.Date = last.Date.Add(day)
42+
last.Daus = 0
43+
newRows = append(newRows, last)
44+
diff -= day
45+
}
46+
47+
newRows = append(newRows, row)
48+
continue
49+
}
50+
51+
return newRows
52+
}
53+
2454
func (api *API) daus(rw http.ResponseWriter, r *http.Request) {
2555
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceMetrics) {
2656
httpapi.Forbidden(rw)
@@ -37,7 +67,7 @@ func (api *API) daus(rw http.ResponseWriter, r *http.Request) {
3767
}
3868

3969
var resp codersdk.GetDAUsResponse
40-
for _, ent := range daus {
70+
for _, ent := range FillEmptyDAUDays(daus) {
4171
resp.Entries = append(resp.Entries, codersdk.DAUEntry{
4272
Date: ent.Date,
4373
DAUs: int(ent.Daus),

coderd/metrics_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package coderd_test
33
import (
44
"context"
55
"os"
6+
"reflect"
67
"testing"
78
"time"
89

@@ -13,6 +14,7 @@ import (
1314
"github.com/coder/coder/agent"
1415
"github.com/coder/coder/coderd"
1516
"github.com/coder/coder/coderd/coderdtest"
17+
"github.com/coder/coder/coderd/database"
1618
"github.com/coder/coder/codersdk"
1719
"github.com/coder/coder/peer"
1820
"github.com/coder/coder/provisioner/echo"
@@ -105,3 +107,106 @@ func TestWorkspaceReportStats(t *testing.T) {
105107
},
106108
}, daus)
107109
}
110+
111+
func date(year, month, day int) time.Time {
112+
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
113+
}
114+
115+
func TestFillEmptyDAUDays(t *testing.T) {
116+
t.Parallel()
117+
118+
type args struct {
119+
rows []database.GetDAUsFromAgentStatsRow
120+
}
121+
tests := []struct {
122+
name string
123+
args args
124+
want []database.GetDAUsFromAgentStatsRow
125+
}{
126+
{"empty", args{}, nil},
127+
{"no holes", args{
128+
rows: []database.GetDAUsFromAgentStatsRow{
129+
{
130+
Date: date(2022, 01, 01),
131+
Daus: 1,
132+
},
133+
{
134+
Date: date(2022, 01, 02),
135+
Daus: 1,
136+
},
137+
{
138+
Date: date(2022, 01, 03),
139+
Daus: 1,
140+
},
141+
},
142+
}, []database.GetDAUsFromAgentStatsRow{
143+
{
144+
Date: date(2022, 01, 01),
145+
Daus: 1,
146+
},
147+
{
148+
Date: date(2022, 01, 02),
149+
Daus: 1,
150+
},
151+
{
152+
Date: date(2022, 01, 03),
153+
Daus: 1,
154+
},
155+
}},
156+
{"holes", args{
157+
rows: []database.GetDAUsFromAgentStatsRow{
158+
{
159+
Date: date(2022, 1, 1),
160+
Daus: 3,
161+
},
162+
{
163+
Date: date(2022, 1, 4),
164+
Daus: 1,
165+
},
166+
{
167+
Date: date(2022, 1, 7),
168+
Daus: 3,
169+
},
170+
},
171+
}, []database.GetDAUsFromAgentStatsRow{
172+
{
173+
Date: date(2022, 1, 1),
174+
Daus: 3,
175+
},
176+
{
177+
Date: date(2022, 1, 2),
178+
Daus: 0,
179+
},
180+
{
181+
Date: date(2022, 1, 3),
182+
Daus: 0,
183+
},
184+
{
185+
Date: date(2022, 1, 4),
186+
Daus: 1,
187+
},
188+
{
189+
Date: date(2022, 1, 5),
190+
Daus: 0,
191+
},
192+
{
193+
Date: date(2022, 1, 6),
194+
Daus: 0,
195+
},
196+
{
197+
Date: date(2022, 1, 7),
198+
Daus: 3,
199+
},
200+
}},
201+
}
202+
for _, tt := range tests {
203+
tt := tt
204+
t.Run(tt.name, func(t *testing.T) {
205+
t.Parallel()
206+
207+
if got := coderd.FillEmptyDAUDays(tt.args.rows); !reflect.DeepEqual(got, tt.want) {
208+
t.Errorf("fillEmptyDAUDays() = %v, want %v", got, tt.want)
209+
}
210+
})
211+
}
212+
}

site/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@xstate/react": "3.0.1",
3939
"axios": "0.26.1",
4040
"can-ndjson-stream": "1.0.2",
41+
"chart.js": "^3.5.0",
4142
"cron-parser": "4.5.0",
4243
"cronstrue": "2.11.0",
4344
"dayjs": "1.11.4",
@@ -47,12 +48,14 @@
4748
"history": "5.3.0",
4849
"i18next": "21.9.1",
4950
"just-debounce-it": "3.0.1",
51+
"moment": "^2.29.4",
5052
"react": "18.2.0",
53+
"react-chartjs-2": "^4.3.1",
5154
"react-dom": "18.2.0",
52-
"react-helmet-async": "1.3.0",
55+
"react-helmet-async": "^1.3.0",
5356
"react-i18next": "11.18.4",
5457
"react-markdown": "8.0.3",
55-
"react-router-dom": "6.3.0",
58+
"react-router-dom": "^6.3.0",
5659
"sourcemapped-stacktrace": "1.1.11",
5760
"swr": "1.3.0",
5861
"tzdata": "1.0.30",

site/src/api/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,8 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
383383
const response = await axios.get("/api/v2/entitlements")
384384
return response.data
385385
}
386+
387+
export const getDAUs = async (): Promise<TypesGen.GetDAUsResponse> => {
388+
const response = await axios.get("/api/v2/metrics/daus")
389+
return response.data
390+
}

site/src/pages/UsersPage/DAUChart.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection"
2+
import { FC } from "react"
3+
4+
import moment from "moment"
5+
import { Line } from "react-chartjs-2"
6+
7+
import * as TypesGen from "../../api/typesGenerated"
8+
9+
export interface DAUChartProps {
10+
userMetricsData: TypesGen.GetDAUsResponse
11+
}
12+
13+
import {
14+
CategoryScale,
15+
Chart as ChartJS,
16+
ChartOptions,
17+
Legend,
18+
LinearScale,
19+
LineElement,
20+
PointElement,
21+
Title,
22+
Tooltip,
23+
} from "chart.js"
24+
25+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
26+
27+
export const DAUChart: FC<DAUChartProps> = ({ userMetricsData }) => {
28+
const labels = userMetricsData.entries.map((val) => {
29+
return moment(val.date).format("l")
30+
})
31+
32+
const data = userMetricsData.entries.map((val) => {
33+
return val.daus
34+
})
35+
36+
const options = {
37+
responsive: true,
38+
plugins: {
39+
legend: {
40+
display: false,
41+
},
42+
},
43+
scales: {
44+
y: {
45+
min: 0,
46+
},
47+
x: {},
48+
},
49+
aspectRatio: 6 / 1,
50+
} as ChartOptions
51+
52+
return (
53+
<>
54+
{/* <p>{JSON.stringify(chartData)}</p> */}
55+
56+
<WorkspaceSection title="Daily Active Users">
57+
<Line
58+
data={{
59+
labels: labels,
60+
datasets: [
61+
{
62+
data: data,
63+
},
64+
],
65+
}}
66+
options={options as any}
67+
height={400}
68+
/>
69+
</WorkspaceSection>
70+
</>
71+
)
72+
}

site/src/pages/UsersPage/UsersPage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useActor } from "@xstate/react"
1+
import { useActor, useMachine } from "@xstate/react"
22
import { FC, ReactNode, useContext, useEffect } from "react"
33
import { Helmet } from "react-helmet-async"
44
import { useNavigate } from "react-router"
55
import { useSearchParams } from "react-router-dom"
6+
import { userMetricsMachine } from "xServices/userMetrics/userMetricsXService"
67
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
78
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
89
import { userFilterQuery } from "../../util/filters"
@@ -44,6 +45,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
4445
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
4546
const { roles } = rolesState.context
4647

48+
const [metricsState] = useMachine(userMetricsMachine)
49+
const { userMetricsData } = metricsState.context
50+
4751
// Is loading if
4852
// - permissions are loading or
4953
// - users are loading or
@@ -79,6 +83,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
7983
<title>{pageTitle("Users")}</title>
8084
</Helmet>
8185
<UsersPageView
86+
userMetricsData={userMetricsData}
8287
roles={roles}
8388
users={users}
8489
openUserCreationDialog={() => {

site/src/pages/UsersPage/UsersPageView.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHea
77
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
88
import { UsersTable } from "../../components/UsersTable/UsersTable"
99
import { userFilterQuery } from "../../util/filters"
10+
import { DAUChart } from "./DAUChart"
1011

1112
export const Language = {
1213
pageTitle: "Users",
@@ -16,6 +17,7 @@ export const Language = {
1617
}
1718

1819
export interface UsersPageViewProps {
20+
userMetricsData?: TypesGen.GetDAUsResponse
1921
users?: TypesGen.User[]
2022
roles?: TypesGen.AssignableRoles[]
2123
filter?: string
@@ -33,6 +35,7 @@ export interface UsersPageViewProps {
3335
}
3436

3537
export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
38+
userMetricsData,
3639
users,
3740
roles,
3841
openUserCreationDialog,
@@ -67,12 +70,16 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
6770
<PageHeaderTitle>{Language.pageTitle}</PageHeaderTitle>
6871
</PageHeader>
6972

70-
<SearchBarWithFilter
71-
filter={filter}
72-
onFilter={onFilter}
73-
presetFilters={presetFilters}
74-
error={error}
75-
/>
73+
{userMetricsData && <DAUChart userMetricsData={userMetricsData} />}
74+
75+
<div style={{ marginTop: "15px" }}>
76+
<SearchBarWithFilter
77+
filter={filter}
78+
onFilter={onFilter}
79+
presetFilters={presetFilters}
80+
error={error}
81+
/>
82+
</div>
7683

7784
<UsersTable
7885
users={users}

0 commit comments

Comments
 (0)