diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bb2f2b602621c..63c07fc22fa19 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -747,7 +747,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.DeploymentDAUsResponse" + "$ref": "#/definitions/codersdk.DAUsResponse" } } } @@ -2124,7 +2124,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.TemplateDAUsResponse" + "$ref": "#/definitions/codersdk.DAUsResponse" } } } @@ -7190,6 +7190,20 @@ const docTemplate = `{ } } }, + "codersdk.DAUsResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.DAUEntry" + } + }, + "tz_hour_offset": { + "type": "integer" + } + } + }, "codersdk.DERP": { "type": "object", "properties": { @@ -7277,17 +7291,6 @@ const docTemplate = `{ } } }, - "codersdk.DeploymentDAUsResponse": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.DAUEntry" - } - } - } - }, "codersdk.DeploymentStats": { "type": "object", "properties": { @@ -8900,17 +8903,6 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.TransitionStats" } }, - "codersdk.TemplateDAUsResponse": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.DAUEntry" - } - } - } - }, "codersdk.TemplateExample": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ba4da7aa72b43..265ba54aad771 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -639,7 +639,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.DeploymentDAUsResponse" + "$ref": "#/definitions/codersdk.DAUsResponse" } } } @@ -1848,7 +1848,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.TemplateDAUsResponse" + "$ref": "#/definitions/codersdk.DAUsResponse" } } } @@ -6392,6 +6392,20 @@ } } }, + "codersdk.DAUsResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.DAUEntry" + } + }, + "tz_hour_offset": { + "type": "integer" + } + } + }, "codersdk.DERP": { "type": "object", "properties": { @@ -6479,17 +6493,6 @@ } } }, - "codersdk.DeploymentDAUsResponse": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.DAUEntry" - } - } - } - }, "codersdk.DeploymentStats": { "type": "object", "properties": { @@ -7994,17 +7997,6 @@ "$ref": "#/definitions/codersdk.TransitionStats" } }, - "codersdk.TemplateDAUsResponse": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.DAUEntry" - } - } - } - }, "codersdk.TemplateExample": { "type": "object", "properties": { diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index 1252788f375ce..f8e504395bc3c 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -205,19 +205,19 @@ func (q *querier) GetTemplateAverageBuildTime(ctx context.Context, arg database. } // Only used by metrics cache. -func (q *querier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]database.GetTemplateDAUsRow, error) { +func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateDAUsParams) ([]database.GetTemplateDAUsRow, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return nil, err } - return q.db.GetTemplateDAUs(ctx, templateID) + return q.db.GetTemplateDAUs(ctx, arg) } // Only used by metrics cache. -func (q *querier) GetDeploymentDAUs(ctx context.Context) ([]database.GetDeploymentDAUsRow, error) { +func (q *querier) GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]database.GetDeploymentDAUsRow, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return nil, err } - return q.db.GetDeploymentDAUs(ctx) + return q.db.GetDeploymentDAUs(ctx, tzOffset) } // UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 41800dbcd9e74..2021c8bcfbe87 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -434,21 +434,21 @@ func (q *fakeQuerier) InsertWorkspaceAgentStat(_ context.Context, p database.Ins return stat, nil } -func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ([]database.GetTemplateDAUsRow, error) { +func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplateDAUsParams) ([]database.GetTemplateDAUsRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() seens := make(map[time.Time]map[uuid.UUID]struct{}) for _, as := range q.workspaceAgentStats { - if as.TemplateID != templateID { + if as.TemplateID != arg.TemplateID { continue } if as.ConnectionCount == 0 { continue } - date := as.CreatedAt.Truncate(time.Hour * 24) + date := as.CreatedAt.UTC().Add(time.Duration(arg.TzOffset) * time.Hour).Truncate(time.Hour * 24) dateEntry := seens[date] if dateEntry == nil { @@ -477,7 +477,7 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ( return rs, nil } -func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context) ([]database.GetDeploymentDAUsRow, error) { +func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context, tzOffset int32) ([]database.GetDeploymentDAUsRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -487,7 +487,7 @@ func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context) ([]database.GetDeploy if as.ConnectionCount == 0 { continue } - date := as.CreatedAt.Truncate(time.Hour * 24) + date := as.CreatedAt.UTC().Add(time.Duration(tzOffset) * time.Hour).Truncate(time.Hour * 24) dateEntry := seens[date] if dateEntry == nil { diff --git a/coderd/database/dbmock/store.go b/coderd/database/dbmock/store.go index 5fe83e6f514d6..a145f24802c07 100644 --- a/coderd/database/dbmock/store.go +++ b/coderd/database/dbmock/store.go @@ -433,18 +433,18 @@ func (mr *MockStoreMockRecorder) GetDERPMeshKey(arg0 interface{}) *gomock.Call { } // GetDeploymentDAUs mocks base method. -func (m *MockStore) GetDeploymentDAUs(arg0 context.Context) ([]database.GetDeploymentDAUsRow, error) { +func (m *MockStore) GetDeploymentDAUs(arg0 context.Context, arg1 int32) ([]database.GetDeploymentDAUsRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDeploymentDAUs", arg0) + ret := m.ctrl.Call(m, "GetDeploymentDAUs", arg0, arg1) ret0, _ := ret[0].([]database.GetDeploymentDAUsRow) ret1, _ := ret[1].(error) return ret0, ret1 } // GetDeploymentDAUs indicates an expected call of GetDeploymentDAUs. -func (mr *MockStoreMockRecorder) GetDeploymentDAUs(arg0 interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetDeploymentDAUs(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeploymentDAUs", reflect.TypeOf((*MockStore)(nil).GetDeploymentDAUs), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeploymentDAUs", reflect.TypeOf((*MockStore)(nil).GetDeploymentDAUs), arg0, arg1) } // GetDeploymentID mocks base method. @@ -1093,7 +1093,7 @@ func (mr *MockStoreMockRecorder) GetTemplateByOrganizationAndName(arg0, arg1 int } // GetTemplateDAUs mocks base method. -func (m *MockStore) GetTemplateDAUs(arg0 context.Context, arg1 uuid.UUID) ([]database.GetTemplateDAUsRow, error) { +func (m *MockStore) GetTemplateDAUs(arg0 context.Context, arg1 database.GetTemplateDAUsParams) ([]database.GetTemplateDAUsRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTemplateDAUs", arg0, arg1) ret0, _ := ret[0].([]database.GetTemplateDAUsRow) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e30207c7ba44e..26978d240623e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -55,7 +55,7 @@ type sqlcQuerier interface { // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) GetDERPMeshKey(ctx context.Context) (string, error) - GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error) + GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) GetDeploymentID(ctx context.Context) (string, error) GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) @@ -101,7 +101,7 @@ type sqlcQuerier interface { GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) - GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) + GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9955deb439ffd..358c5af7934a8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6335,7 +6335,7 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentStats(ctx context.Context) error { const getDeploymentDAUs = `-- name: GetDeploymentDAUs :many SELECT - (created_at at TIME ZONE 'UTC')::date as date, + (created_at at TIME ZONE cast($1::integer as text))::date as date, user_id FROM workspace_agent_stats @@ -6352,8 +6352,8 @@ type GetDeploymentDAUsRow struct { UserID uuid.UUID `db:"user_id" json:"user_id"` } -func (q *sqlQuerier) GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error) { - rows, err := q.db.QueryContext(ctx, getDeploymentDAUs) +func (q *sqlQuerier) GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) { + rows, err := q.db.QueryContext(ctx, getDeploymentDAUs, tzOffset) if err != nil { return nil, err } @@ -6428,7 +6428,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceAgentStats(ctx context.Context, creat const getTemplateDAUs = `-- name: GetTemplateDAUs :many SELECT - (created_at at TIME ZONE 'UTC')::date as date, + (created_at at TIME ZONE cast($2::integer as text))::date as date, user_id FROM workspace_agent_stats @@ -6441,13 +6441,18 @@ ORDER BY date ASC ` +type GetTemplateDAUsParams struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TzOffset int32 `db:"tz_offset" json:"tz_offset"` +} + type GetTemplateDAUsRow struct { Date time.Time `db:"date" json:"date"` UserID uuid.UUID `db:"user_id" json:"user_id"` } -func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) { - rows, err := q.db.QueryContext(ctx, getTemplateDAUs, templateID) +func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateDAUs, arg.TemplateID, arg.TzOffset) if err != nil { return nil, err } diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 4432fbcdaf663..1a598bd6a6263 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -24,7 +24,7 @@ VALUES -- name: GetTemplateDAUs :many SELECT - (created_at at TIME ZONE 'UTC')::date as date, + (created_at at TIME ZONE cast(@tz_offset::integer as text))::date as date, user_id FROM workspace_agent_stats @@ -38,7 +38,7 @@ ORDER BY -- name: GetDeploymentDAUs :many SELECT - (created_at at TIME ZONE 'UTC')::date as date, + (created_at at TIME ZONE cast(@tz_offset::integer as text))::date as date, user_id FROM workspace_agent_stats diff --git a/coderd/insights.go b/coderd/insights.go index 79cf14210ec6e..b1ed1b2dc379d 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -13,7 +13,7 @@ import ( // @Security CoderSessionToken // @Produce json // @Tags Insights -// @Success 200 {object} codersdk.DeploymentDAUsResponse +// @Success 200 {object} codersdk.DAUsResponse // @Router /insights/daus [get] func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -22,9 +22,21 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) { return } - resp, _ := api.metricsCache.DeploymentDAUs() + vals := r.URL.Query() + p := httpapi.NewQueryParamParser() + tzOffset := p.Int(vals, 0, "tz_offset") + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + _, resp, _ := api.metricsCache.DeploymentDAUs(tzOffset) if resp == nil || resp.Entries == nil { - httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DeploymentDAUsResponse{ + httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DAUsResponse{ Entries: []codersdk.DAUEntry{}, }) return diff --git a/coderd/insights_test.go b/coderd/insights_test.go index ada90a916253a..20ddc6bb62a29 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -55,7 +55,7 @@ func TestDeploymentInsights(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - daus, err := client.DeploymentDAUs(context.Background()) + daus, err := client.DeploymentDAUs(context.Background(), codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) @@ -74,7 +74,7 @@ func TestDeploymentInsights(t *testing.T) { require.NoError(t, err) _ = sshConn.Close() - wantDAUs := &codersdk.DeploymentDAUsResponse{ + wantDAUs := &codersdk.DAUsResponse{ Entries: []codersdk.DAUEntry{ { Date: time.Now().UTC().Truncate(time.Hour * 24), @@ -83,14 +83,14 @@ func TestDeploymentInsights(t *testing.T) { }, } require.Eventuallyf(t, func() bool { - daus, err = client.DeploymentDAUs(ctx) + daus, err = client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) return len(daus.Entries) > 0 }, testutil.WaitShort, testutil.IntervalFast, "deployment daus never loaded", ) - gotDAUs, err := client.DeploymentDAUs(ctx) + gotDAUs, err := client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) require.Equal(t, gotDAUs, wantDAUs) diff --git a/coderd/metricscache/metrics_internal_test.go b/coderd/metricscache/metrics_internal_test.go new file mode 100644 index 0000000000000..97f036b766327 --- /dev/null +++ b/coderd/metricscache/metrics_internal_test.go @@ -0,0 +1,93 @@ +package metricscache + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClosest(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Keys []int + Input int + Expected int + NotFound bool + }{ + { + Name: "Empty", + Input: 10, + NotFound: true, + }, + { + Name: "Equal", + Keys: []int{1, 2, 3, 4, 5, 6, 10, 12, 15}, + Input: 10, + Expected: 10, + }, + { + Name: "ZeroOnly", + Keys: []int{0}, + Input: 10, + Expected: 0, + }, + { + Name: "NegativeOnly", + Keys: []int{-10, -5}, + Input: 10, + Expected: -5, + }, + { + Name: "CloseBothSides", + Keys: []int{-10, -5, 0, 5, 8, 12}, + Input: 10, + Expected: 8, + }, + { + Name: "CloseNoZero", + Keys: []int{-10, -5, 5, 8, 12}, + Input: 0, + Expected: -5, + }, + { + Name: "CloseLeft", + Keys: []int{-10, -5, 0, 5, 8, 12}, + Input: 20, + Expected: 12, + }, + { + Name: "CloseRight", + Keys: []int{-10, -5, 0, 5, 8, 12}, + Input: -20, + Expected: -10, + }, + { + Name: "ChooseZero", + Keys: []int{-10, -5, 0, 5, 8, 12}, + Input: 2, + Expected: 0, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + m := make(map[int]int) + for _, k := range tc.Keys { + m[k] = k + } + + found, _, ok := closest(m, tc.Input) + if tc.NotFound { + require.False(t, ok, "should not be found") + } else { + require.True(t, ok) + require.Equal(t, tc.Expected, found, "closest") + } + }) + } +} diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index 7d8ce52ffd452..e70f193ee0c3c 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -3,14 +3,16 @@ package metricscache import ( "context" "database/sql" + "fmt" + "math" "sync" "sync/atomic" "time" + "github.com/google/uuid" "golang.org/x/exp/maps" "golang.org/x/exp/slices" - - "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/coderd/database" @@ -19,6 +21,14 @@ import ( "github.com/coder/retry" ) +// timezoneOffsets are the timezones that are cached and supported. +// Any non-listed timezone offsets will need to use the closest supported one. +var timezoneOffsets = []int{ + 0, // UTC - is listed first intentionally. + -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, +} + // Cache holds the template metrics. // The aggregation queries responsible for these values can take up to a minute // on large deployments. Even in small deployments, aggregation queries can @@ -29,8 +39,8 @@ type Cache struct { log slog.Logger intervals Intervals - deploymentDAUResponses atomic.Pointer[codersdk.DeploymentDAUsResponse] - templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] + deploymentDAUResponses atomic.Pointer[map[int]codersdk.DAUsResponse] + templateDAUResponses atomic.Pointer[map[int]map[uuid.UUID]codersdk.DAUsResponse] templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow] deploymentStatsResponse atomic.Pointer[codersdk.DeploymentStats] @@ -107,37 +117,23 @@ func fillEmptyDays(sortedDates []time.Time) []time.Time { return newDates } -func convertDAUResponse(rows []database.GetTemplateDAUsRow) codersdk.TemplateDAUsResponse { - respMap := make(map[time.Time][]uuid.UUID) - for _, row := range rows { - uuids := respMap[row.Date] - if uuids == nil { - uuids = make([]uuid.UUID, 0, 8) - } - uuids = append(uuids, row.UserID) - respMap[row.Date] = uuids - } - - dates := maps.Keys(respMap) - slices.SortFunc(dates, func(a, b time.Time) bool { - return a.Before(b) - }) - - var resp codersdk.TemplateDAUsResponse - for _, date := range fillEmptyDays(dates) { - resp.Entries = append(resp.Entries, codersdk.DAUEntry{ - Date: date, - Amount: len(respMap[date]), - }) - } - - return resp +type dauRow interface { + database.GetTemplateDAUsRow | + database.GetDeploymentDAUsRow } -func convertDeploymentDAUResponse(rows []database.GetDeploymentDAUsRow) codersdk.DeploymentDAUsResponse { +func convertDAUResponse[T dauRow](rows []T, tzOffset int) codersdk.DAUsResponse { respMap := make(map[time.Time][]uuid.UUID) for _, row := range rows { - respMap[row.Date] = append(respMap[row.Date], row.UserID) + switch row := any(row).(type) { + case database.GetDeploymentDAUsRow: + respMap[row.Date] = append(respMap[row.Date], row.UserID) + case database.GetTemplateDAUsRow: + respMap[row.Date] = append(respMap[row.Date], row.UserID) + default: + // This should never happen. + panic(fmt.Sprintf("%T not acceptable, developer error", row)) + } } dates := maps.Keys(respMap) @@ -145,13 +141,14 @@ func convertDeploymentDAUResponse(rows []database.GetDeploymentDAUsRow) codersdk return a.Before(b) }) - var resp codersdk.DeploymentDAUsResponse + var resp codersdk.DAUsResponse for _, date := range fillEmptyDays(dates) { resp.Entries = append(resp.Entries, codersdk.DAUEntry{ Date: date, Amount: len(respMap[date]), }) } + resp.TZHourOffset = tzOffset return resp } @@ -164,6 +161,23 @@ func countUniqueUsers(rows []database.GetTemplateDAUsRow) int { return len(seen) } +func (c *Cache) refreshDeploymentDAUs(ctx context.Context) error { + //nolint:gocritic // This is a system service. + ctx = dbauthz.AsSystemRestricted(ctx) + + deploymentDAUs := make(map[int]codersdk.DAUsResponse) + for _, tzOffset := range timezoneOffsets { + rows, err := c.database.GetDeploymentDAUs(ctx, int32(tzOffset)) + if err != nil { + return err + } + deploymentDAUs[tzOffset] = convertDAUResponse(rows, tzOffset) + } + + c.deploymentDAUResponses.Store(&deploymentDAUs) + return nil +} + func (c *Cache) refreshTemplateDAUs(ctx context.Context) error { //nolint:gocritic // This is a system service. ctx = dbauthz.AsSystemRestricted(ctx) @@ -174,26 +188,35 @@ func (c *Cache) refreshTemplateDAUs(ctx context.Context) error { } var ( - deploymentDAUs = codersdk.DeploymentDAUsResponse{} - templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) + templateDAUs = make(map[int]map[uuid.UUID]codersdk.DAUsResponse, len(templates)) templateUniqueUsers = make(map[uuid.UUID]int) templateAverageBuildTimes = make(map[uuid.UUID]database.GetTemplateAverageBuildTimeRow) ) - rows, err := c.database.GetDeploymentDAUs(ctx) + err = c.refreshDeploymentDAUs(ctx) if err != nil { - return err + return xerrors.Errorf("deployment daus: %w", err) } - deploymentDAUs = convertDeploymentDAUResponse(rows) - c.deploymentDAUResponses.Store(&deploymentDAUs) for _, template := range templates { - rows, err := c.database.GetTemplateDAUs(ctx, template.ID) - if err != nil { - return err + for _, tzOffset := range timezoneOffsets { + rows, err := c.database.GetTemplateDAUs(ctx, database.GetTemplateDAUsParams{ + TemplateID: template.ID, + TzOffset: int32(tzOffset), + }) + if err != nil { + return err + } + if templateDAUs[tzOffset] == nil { + templateDAUs[tzOffset] = make(map[uuid.UUID]codersdk.DAUsResponse) + } + templateDAUs[tzOffset][template.ID] = convertDAUResponse(rows, tzOffset) + if _, set := templateUniqueUsers[template.ID]; !set { + // If the uniqueUsers has not been counted yet, set the unique count with the rows we have. + // We only need to calculate this once. + templateUniqueUsers[template.ID] = countUniqueUsers(rows) + } } - templateDAUs[template.ID] = convertDAUResponse(rows) - templateUniqueUsers[template.ID] = countUniqueUsers(rows) templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ TemplateID: uuid.NullUUID{ @@ -294,26 +317,80 @@ func (c *Cache) Close() error { return nil } -func (c *Cache) DeploymentDAUs() (*codersdk.DeploymentDAUsResponse, bool) { +func (c *Cache) DeploymentDAUs(offset int) (int, *codersdk.DAUsResponse, bool) { m := c.deploymentDAUResponses.Load() - return m, m != nil + if m == nil { + return 0, nil, false + } + closestOffset, resp, ok := closest(*m, offset) + if !ok { + return 0, nil, false + } + return closestOffset, &resp, ok } // TemplateDAUs returns an empty response if the template doesn't have users // or is loading for the first time. -func (c *Cache) TemplateDAUs(id uuid.UUID) (*codersdk.TemplateDAUsResponse, bool) { +// The cache will select the closest DAUs response to given timezone offset. +func (c *Cache) TemplateDAUs(id uuid.UUID, offset int) (int, *codersdk.DAUsResponse, bool) { m := c.templateDAUResponses.Load() if m == nil { // Data loading. - return nil, false + return 0, nil, false } - resp, ok := (*m)[id] + closestOffset, resp, ok := closest(*m, offset) if !ok { // Probably no data. - return nil, false + return 0, nil, false + } + + tpl, ok := resp[id] + if !ok { + // Probably no data. + return 0, nil, false + } + + return closestOffset, &tpl, true +} + +// closest returns the value in the values map that has a key with the value most +// close to the requested key. This is so if a user requests a timezone offset that +// we do not have, we return the closest one we do have to the user. +func closest[V any](values map[int]V, offset int) (int, V, bool) { + if len(values) == 0 { + var v V + return -1, v, false + } + + v, ok := values[offset] + if ok { + // We have the exact offset, that was easy! + return offset, v, true + } + + var closest int + var closestV V + diff := math.MaxInt + for k, v := range values { + newDiff := abs(k - offset) + // Take the closest value that is also the smallest value. We do this + // to make the output deterministic + if newDiff < diff || (newDiff == diff && k < closest) { + // new closest + closest = k + closestV = v + diff = newDiff + } + } + return closest, closestV, true +} + +func abs(a int) int { + if a < 0 { + return -1 * a } - return &resp, true + return a } // TemplateUniqueUsers returns the number of unique Template users diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 90d5e93e1d750..ee6c9e8e54884 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -18,12 +18,22 @@ import ( "github.com/coder/coder/testutil" ) +func dateH(year, month, day, hour int) time.Time { + return time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.UTC) +} + func date(year, month, day int) time.Time { return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) } func TestCache_TemplateUsers(t *testing.T) { t.Parallel() + statRow := func(user uuid.UUID, date time.Time) database.InsertWorkspaceAgentStatParams { + return database.InsertWorkspaceAgentStatParams{ + CreatedAt: date, + UserID: user, + } + } var ( zebra = uuid.UUID{1} @@ -38,24 +48,21 @@ func TestCache_TemplateUsers(t *testing.T) { uniqueUsers int } tests := []struct { - name string - args args - want want + name string + args args + want want + tzOffset int }{ - {"empty", args{}, want{nil, 0}}, + {name: "empty", args: args{}, want: want{nil, 0}}, { - "one hole", args{ + name: "one hole", + args: args{ rows: []database.InsertWorkspaceAgentStatParams{ - { - CreatedAt: date(2022, 8, 27), - UserID: zebra, - }, - { - CreatedAt: date(2022, 8, 30), - UserID: zebra, - }, + statRow(zebra, dateH(2022, 8, 27, 0)), + statRow(zebra, dateH(2022, 8, 30, 0)), }, - }, want{[]codersdk.DAUEntry{ + }, + want: want{[]codersdk.DAUEntry{ { Date: date(2022, 8, 27), Amount: 1, @@ -74,88 +81,113 @@ func TestCache_TemplateUsers(t *testing.T) { }, }, 1}, }, - {"no holes", args{ - rows: []database.InsertWorkspaceAgentStatParams{ + { + name: "no holes", + args: args{ + rows: []database.InsertWorkspaceAgentStatParams{ + statRow(zebra, dateH(2022, 8, 27, 0)), + statRow(zebra, dateH(2022, 8, 28, 0)), + statRow(zebra, dateH(2022, 8, 29, 0)), + }, + }, + want: want{[]codersdk.DAUEntry{ { - CreatedAt: date(2022, 8, 27), - UserID: zebra, + Date: date(2022, 8, 27), + Amount: 1, }, { - CreatedAt: date(2022, 8, 28), - UserID: zebra, + Date: date(2022, 8, 28), + Amount: 1, }, { - CreatedAt: date(2022, 8, 29), - UserID: zebra, + Date: date(2022, 8, 29), + Amount: 1, + }, + }, 1}, + }, + { + name: "holes", + args: args{ + rows: []database.InsertWorkspaceAgentStatParams{ + statRow(zebra, dateH(2022, 1, 1, 0)), + statRow(tiger, dateH(2022, 1, 1, 0)), + statRow(zebra, dateH(2022, 1, 4, 0)), + statRow(zebra, dateH(2022, 1, 7, 0)), + statRow(tiger, dateH(2022, 1, 7, 0)), }, }, - }, want{[]codersdk.DAUEntry{ - { - Date: date(2022, 8, 27), - Amount: 1, - }, - { - Date: date(2022, 8, 28), - Amount: 1, - }, - { - Date: date(2022, 8, 29), - Amount: 1, - }, - }, 1}}, - {"holes", args{ - rows: []database.InsertWorkspaceAgentStatParams{ + want: want{[]codersdk.DAUEntry{ { - CreatedAt: date(2022, 1, 1), - UserID: zebra, + Date: date(2022, 1, 1), + Amount: 2, }, { - CreatedAt: date(2022, 1, 1), - UserID: tiger, + Date: date(2022, 1, 2), + Amount: 0, }, { - CreatedAt: date(2022, 1, 4), - UserID: zebra, + Date: date(2022, 1, 3), + Amount: 0, }, { - CreatedAt: date(2022, 1, 7), - UserID: zebra, + Date: date(2022, 1, 4), + Amount: 1, }, { - CreatedAt: date(2022, 1, 7), - UserID: tiger, + Date: date(2022, 1, 5), + Amount: 0, + }, + { + Date: date(2022, 1, 6), + Amount: 0, + }, + { + Date: date(2022, 1, 7), + Amount: 2, + }, + }, 2}, + }, + { + name: "tzOffset", + tzOffset: -1, + args: args{ + rows: []database.InsertWorkspaceAgentStatParams{ + statRow(zebra, dateH(2022, 1, 2, 1)), + statRow(tiger, dateH(2022, 1, 2, 1)), + // With offset these should be in the previous day + statRow(zebra, dateH(2022, 1, 2, 0)), + statRow(tiger, dateH(2022, 1, 2, 0)), }, }, - }, want{[]codersdk.DAUEntry{ - { - Date: date(2022, 1, 1), - Amount: 2, - }, - { - Date: date(2022, 1, 2), - Amount: 0, - }, - { - Date: date(2022, 1, 3), - Amount: 0, - }, - { - Date: date(2022, 1, 4), - Amount: 1, - }, - { - Date: date(2022, 1, 5), - Amount: 0, - }, - { - Date: date(2022, 1, 6), - Amount: 0, - }, - { - Date: date(2022, 1, 7), - Amount: 2, + want: want{[]codersdk.DAUEntry{ + { + Date: date(2022, 1, 1), + Amount: 2, + }, + { + Date: date(2022, 1, 2), + Amount: 2, + }, + }, 2}, + }, + { + name: "tzOffsetPreviousDay", + tzOffset: -6, + args: args{ + rows: []database.InsertWorkspaceAgentStatParams{ + statRow(zebra, dateH(2022, 1, 2, 1)), + statRow(tiger, dateH(2022, 1, 2, 1)), + statRow(zebra, dateH(2022, 1, 2, 0)), + statRow(tiger, dateH(2022, 1, 2, 0)), + }, }, - }, 2}}, + want: want{[]codersdk.DAUEntry{ + { + Date: date(2022, 1, 1), + Amount: 2, + }, + }, 2}, + }, } for _, tt := range tests { @@ -182,7 +214,7 @@ func TestCache_TemplateUsers(t *testing.T) { } require.Eventuallyf(t, func() bool { - _, ok := cache.TemplateDAUs(template.ID) + _, _, ok := cache.TemplateDAUs(template.ID, tt.tzOffset) return ok }, testutil.WaitShort, testutil.IntervalMedium, "TemplateDAUs never populated", @@ -191,8 +223,9 @@ func TestCache_TemplateUsers(t *testing.T) { gotUniqueUsers, ok := cache.TemplateUniqueUsers(template.ID) require.True(t, ok) - gotEntries, ok := cache.TemplateDAUs(template.ID) + offset, gotEntries, ok := cache.TemplateDAUs(template.ID, tt.tzOffset) require.True(t, ok) + require.Equal(t, offset, tt.tzOffset) require.Equal(t, tt.want.entries, gotEntries.Entries) require.Equal(t, tt.want.uniqueUsers, gotUniqueUsers) }) diff --git a/coderd/templates.go b/coderd/templates.go index f15cf1ec3b3f6..ee73c8e43c833 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -614,15 +614,27 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Templates // @Param template path string true "Template ID" format(uuid) -// @Success 200 {object} codersdk.TemplateDAUsResponse +// @Success 200 {object} codersdk.DAUsResponse // @Router /templates/{template}/daus [get] func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() template := httpmw.TemplateParam(r) - resp, _ := api.metricsCache.TemplateDAUs(template.ID) + vals := r.URL.Query() + p := httpapi.NewQueryParamParser() + tzOffset := p.Int(vals, 0, "tz_offset") + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + _, resp, _ := api.metricsCache.TemplateDAUs(template.ID, tzOffset) if resp == nil || resp.Entries == nil { - httpapi.Write(ctx, rw, http.StatusOK, &codersdk.TemplateDAUsResponse{ + httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DAUsResponse{ Entries: []codersdk.DAUEntry{}, }) return diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 34e1637454ad6..03a3556f56e1a 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -978,10 +978,10 @@ func TestTemplateMetrics(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - daus, err := client.TemplateDAUs(context.Background(), template.ID) + daus, err := client.TemplateDAUs(context.Background(), template.ID, codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) - require.Equal(t, &codersdk.TemplateDAUsResponse{ + require.Equal(t, &codersdk.DAUsResponse{ Entries: []codersdk.DAUEntry{}, }, daus, "no DAUs when stats are empty") @@ -1001,7 +1001,7 @@ func TestTemplateMetrics(t *testing.T) { require.NoError(t, err) _ = sshConn.Close() - wantDAUs := &codersdk.TemplateDAUsResponse{ + wantDAUs := &codersdk.DAUsResponse{ Entries: []codersdk.DAUEntry{ { Date: time.Now().UTC().Truncate(time.Hour * 24), @@ -1010,14 +1010,14 @@ func TestTemplateMetrics(t *testing.T) { }, } require.Eventuallyf(t, func() bool { - daus, err = client.TemplateDAUs(ctx, template.ID) + daus, err = client.TemplateDAUs(ctx, template.ID, codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) return len(daus.Entries) > 0 }, testutil.WaitShort, testutil.IntervalFast, "template daus never loaded", ) - gotDAUs, err := client.TemplateDAUs(ctx, template.ID) + gotDAUs, err := client.TemplateDAUs(ctx, template.ID, codersdk.TimezoneOffsetHour(time.UTC)) require.NoError(t, err) require.Equal(t, gotDAUs, wantDAUs) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 84c295aefee7f..d285a78bcb647 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1720,12 +1720,48 @@ func (c *Client) Experiments(ctx context.Context) (Experiments, error) { return exp, json.NewDecoder(res.Body).Decode(&exp) } -type DeploymentDAUsResponse struct { - Entries []DAUEntry `json:"entries"` +type DAUsResponse struct { + Entries []DAUEntry `json:"entries"` + TZHourOffset int `json:"tz_hour_offset"` } -func (c *Client) DeploymentDAUs(ctx context.Context) (*DeploymentDAUsResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/insights/daus", nil) +type DAUEntry struct { + Date time.Time `json:"date" format:"date-time"` + Amount int `json:"amount"` +} + +type DAURequest struct { + TZHourOffset int +} + +func (d DAURequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + q.Set("tz_offset", strconv.Itoa(d.TZHourOffset)) + r.URL.RawQuery = q.Encode() + } +} + +func TimezoneOffsetHour(loc *time.Location) int { + if loc == nil { + // Default to UTC time to be consistent across all callers. + loc = time.UTC + } + _, offsetSec := time.Now().In(loc).Zone() + // Convert to hours + return offsetSec / 60 / 60 +} + +func (c *Client) DeploymentDAUsLocalTZ(ctx context.Context) (*DAUsResponse, error) { + return c.DeploymentDAUs(ctx, TimezoneOffsetHour(time.Local)) +} + +// DeploymentDAUs requires a tzOffset in hours. Use 0 for UTC, and TimezoneOffsetHour(time.Local) for the +// local timezone. +func (c *Client) DeploymentDAUs(ctx context.Context, tzOffset int) (*DAUsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/insights/daus", nil, DAURequest{ + TZHourOffset: tzOffset, + }.asRequestOption()) if err != nil { return nil, xerrors.Errorf("execute request: %w", err) } @@ -1735,7 +1771,7 @@ func (c *Client) DeploymentDAUs(ctx context.Context) (*DeploymentDAUsResponse, e return nil, ReadBodyAsError(res) } - var resp DeploymentDAUsResponse + var resp DAUsResponse return &resp, json.NewDecoder(res.Body).Decode(&resp) } diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 454995dd22418..eb4e267364c98 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -3,6 +3,7 @@ package codersdk_test import ( "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -195,3 +196,59 @@ func TestSSHConfig_ParseOptions(t *testing.T) { }) } } + +func TestTimezoneOffsets(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Loc *time.Location + ExpectedOffset int + }{ + { + Name: "UTX", + Loc: time.UTC, + ExpectedOffset: 0, + }, + { + Name: "Eastern", + Loc: must(time.LoadLocation("America/New_York")), + ExpectedOffset: -4, + }, + { + Name: "Central", + Loc: must(time.LoadLocation("America/Chicago")), + ExpectedOffset: -5, + }, + { + Name: "Ireland", + Loc: must(time.LoadLocation("Europe/Dublin")), + ExpectedOffset: 1, + }, + { + Name: "HalfHourTz", + // This timezone is +6:30, but the function rounds to the nearest hour. + // This is intentional because our DAUs endpoint only covers 1-hour offsets. + // If the user is in a non-hour timezone, they get the closest hour bucket. + Loc: must(time.LoadLocation("Asia/Yangon")), + ExpectedOffset: 6, + }, + } + + for _, c := range testCases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + offset := codersdk.TimezoneOffsetHour(c.Loc) + require.Equal(t, c.ExpectedOffset, offset) + }) + } +} + +func must[T any](value T, err error) T { + if err != nil { + panic(err) + } + return value +} diff --git a/codersdk/templates.go b/codersdk/templates.go index b1f258d9daa5b..1fc6196832e3e 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -232,18 +232,16 @@ func (c *Client) TemplateVersionByName(ctx context.Context, template uuid.UUID, return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion) } -type DAUEntry struct { - Date time.Time `json:"date" format:"date-time"` - Amount int `json:"amount"` +func (c *Client) TemplateDAUsLocalTZ(ctx context.Context, templateID uuid.UUID) (*DAUsResponse, error) { + return c.TemplateDAUs(ctx, templateID, TimezoneOffsetHour(time.Local)) } -// TemplateDAUsResponse contains statistics of daily active users of the template. -type TemplateDAUsResponse struct { - Entries []DAUEntry `json:"entries"` -} - -func (c *Client) TemplateDAUs(ctx context.Context, templateID uuid.UUID) (*TemplateDAUsResponse, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/daus", templateID), nil) +// TemplateDAUs requires a tzOffset in hours. Use 0 for UTC, and TimezoneOffsetHour(time.Local) for the +// local timezone. +func (c *Client) TemplateDAUs(ctx context.Context, templateID uuid.UUID, tzOffset int) (*DAUsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/daus", templateID), nil, DAURequest{ + TZHourOffset: tzOffset, + }.asRequestOption()) if err != nil { return nil, xerrors.Errorf("execute request: %w", err) } @@ -253,7 +251,7 @@ func (c *Client) TemplateDAUs(ctx context.Context, templateID uuid.UUID) (*Templ return nil, ReadBodyAsError(res) } - var resp TemplateDAUsResponse + var resp DAUsResponse return &resp, json.NewDecoder(res.Body).Decode(&resp) } diff --git a/docs/api/insights.md b/docs/api/insights.md index b72dec3c3dc05..90f236d1574cf 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -24,14 +24,15 @@ curl -X GET http://coder-server:8080/api/v2/insights/daus \ "amount": 0, "date": "2019-08-24T14:15:22Z" } - ] + ], + "tz_hour_offset": 0 } ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DeploymentDAUsResponse](schemas.md#codersdkdeploymentdausresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DAUsResponse](schemas.md#codersdkdausresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index e428c8bc2f9c3..676021aa17bcd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1690,6 +1690,27 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `amount` | integer | false | | | | `date` | string | false | | | +## codersdk.DAUsResponse + +```json +{ + "entries": [ + { + "amount": 0, + "date": "2019-08-24T14:15:22Z" + } + ], + "tz_hour_offset": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `entries` | array of [codersdk.DAUEntry](#codersdkdauentry) | false | | | +| `tz_hour_offset` | integer | false | | | + ## codersdk.DERP ```json @@ -2102,25 +2123,6 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `config` | [codersdk.DeploymentValues](#codersdkdeploymentvalues) | false | | | | `options` | array of [clibase.Option](#clibaseoption) | false | | | -## codersdk.DeploymentDAUsResponse - -```json -{ - "entries": [ - { - "amount": 0, - "date": "2019-08-24T14:15:22Z" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| --------- | ----------------------------------------------- | -------- | ------------ | ----------- | -| `entries` | array of [codersdk.DAUEntry](#codersdkdauentry) | false | | | - ## codersdk.DeploymentStats ```json @@ -3979,25 +3981,6 @@ Parameter represents a set value for the scope. | ---------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | | `[any property]` | [codersdk.TransitionStats](#codersdktransitionstats) | false | | | -## codersdk.TemplateDAUsResponse - -```json -{ - "entries": [ - { - "amount": 0, - "date": "2019-08-24T14:15:22Z" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| --------- | ----------------------------------------------- | -------- | ------------ | ----------- | -| `entries` | array of [codersdk.DAUEntry](#codersdkdauentry) | false | | | - ## codersdk.TemplateExample ```json diff --git a/docs/api/templates.md b/docs/api/templates.md index 829f43f410216..3c17f36329d35 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -801,15 +801,16 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/daus \ "amount": 0, "date": "2019-08-24T14:15:22Z" } - ] + ], + "tz_hour_offset": 0 } ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateDAUsResponse](schemas.md#codersdktemplatedausresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DAUsResponse](schemas.md#codersdkdausresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 31a634997427d..1fc8ed56f9b50 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -826,16 +826,15 @@ export const getAuditLogs = async ( export const getTemplateDAUs = async ( templateId: string, -): Promise => { +): Promise => { const response = await axios.get(`/api/v2/templates/${templateId}/daus`) return response.data } -export const getDeploymentDAUs = - async (): Promise => { - const response = await axios.get(`/api/v2/insights/daus`) - return response.data - } +export const getDeploymentDAUs = async (): Promise => { + const response = await axios.get(`/api/v2/insights/daus`) + return response.data +} export const getTemplateACL = async ( templateId: string, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 768c74f8f640e..4555c007ae559 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -273,12 +273,23 @@ export interface CreateWorkspaceRequest { readonly rich_parameter_values?: WorkspaceBuildParameter[] } -// From codersdk/templates.go +// From codersdk/deployment.go export interface DAUEntry { readonly date: string readonly amount: number } +// From codersdk/deployment.go +export interface DAURequest { + readonly TZHourOffset: number +} + +// From codersdk/deployment.go +export interface DAUsResponse { + readonly entries: DAUEntry[] + readonly tz_hour_offset: number +} + // From codersdk/deployment.go export interface DERP { readonly server: DERPServerConfig @@ -315,11 +326,6 @@ export interface DangerousConfig { readonly allow_all_cors: boolean } -// From codersdk/deployment.go -export interface DeploymentDAUsResponse { - readonly entries: DAUEntry[] -} - // From codersdk/deployment.go export interface DeploymentStats { readonly aggregated_from: string @@ -863,11 +869,6 @@ export type TemplateBuildTimeStats = Record< TransitionStats > -// From codersdk/templates.go -export interface TemplateDAUsResponse { - readonly entries: DAUEntry[] -} - // From codersdk/templates.go export interface TemplateExample { readonly id: string diff --git a/site/src/components/DAUChart/DAUChart.test.tsx b/site/src/components/DAUChart/DAUChart.test.tsx index 9a48c1069faef..6a6866a5d095d 100644 --- a/site/src/components/DAUChart/DAUChart.test.tsx +++ b/site/src/components/DAUChart/DAUChart.test.tsx @@ -14,6 +14,7 @@ describe("DAUChart", () => { render( , @@ -25,6 +26,7 @@ describe("DAUChart", () => { render( , diff --git a/site/src/components/DAUChart/DAUChart.tsx b/site/src/components/DAUChart/DAUChart.tsx index 1ccfa00114b4e..2b9c508b2060b 100644 --- a/site/src/components/DAUChart/DAUChart.tsx +++ b/site/src/components/DAUChart/DAUChart.tsx @@ -38,7 +38,7 @@ ChartJS.register( ) export interface DAUChartProps { - daus: TypesGen.TemplateDAUsResponse | TypesGen.DeploymentDAUsResponse + daus: TypesGen.DAUsResponse } export const Language = { loadingText: "DAU stats are loading. Check back later.", diff --git a/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx index d0c15de104390..035748b3eb1ce 100644 --- a/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx +++ b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx @@ -5,7 +5,7 @@ import { Sidebar } from "./Sidebar" import { createContext, Suspense, useContext, FC } from "react" import { useMachine } from "@xstate/react" import { Loader } from "components/Loader/Loader" -import { DeploymentDAUsResponse } from "api/typesGenerated" +import { DAUsResponse } from "api/typesGenerated" import { deploymentConfigMachine } from "xServices/deploymentConfig/deploymentConfigMachine" import { RequirePermission } from "components/RequirePermission/RequirePermission" import { usePermissions } from "hooks/usePermissions" @@ -15,7 +15,7 @@ import { DeploymentConfig } from "api/types" type DeploySettingsContextValue = { deploymentValues: DeploymentConfig getDeploymentValuesError: unknown - deploymentDAUs?: DeploymentDAUsResponse + deploymentDAUs?: DAUsResponse getDeploymentDAUsError: unknown } diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index b63fb74a2d1c3..4b664de25a570 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,5 +1,5 @@ import { DeploymentOption } from "api/types" -import { DeploymentDAUsResponse } from "api/typesGenerated" +import { DAUsResponse } from "api/typesGenerated" import { ErrorAlert } from "components/Alert/ErrorAlert" import { DAUChart } from "components/DAUChart/DAUChart" import { Header } from "components/DeploySettingsLayout/Header" @@ -9,7 +9,7 @@ import { useDeploymentOptions } from "utils/deployOptions" export type GeneralSettingsPageViewProps = { deploymentOptions: DeploymentOption[] - deploymentDAUs?: DeploymentDAUsResponse + deploymentDAUs?: DAUsResponse getDeploymentDAUsError: unknown } export const GeneralSettingsPageView = ({ diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0039cf19a84e6..36082228d4f8c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -16,14 +16,16 @@ export const MockOrganization: TypesGen.Organization = { updated_at: "", } -export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = { +export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { + tz_hour_offset: 0, entries: [ { date: "2022-08-27T00:00:00Z", amount: 1 }, { date: "2022-08-29T00:00:00Z", amount: 2 }, { date: "2022-08-30T00:00:00Z", amount: 1 }, ], } -export const MockDeploymentDAUResponse: TypesGen.DeploymentDAUsResponse = { +export const MockDeploymentDAUResponse: TypesGen.DAUsResponse = { + tz_hour_offset: 0, entries: [ { date: "2022-08-27T00:00:00Z", amount: 1 }, { date: "2022-08-29T00:00:00Z", amount: 2 }, diff --git a/site/src/xServices/deploymentConfig/deploymentConfigMachine.ts b/site/src/xServices/deploymentConfig/deploymentConfigMachine.ts index 343bdd7fd3b1b..7c4fc4c074720 100644 --- a/site/src/xServices/deploymentConfig/deploymentConfigMachine.ts +++ b/site/src/xServices/deploymentConfig/deploymentConfigMachine.ts @@ -1,4 +1,4 @@ -import { DeploymentDAUsResponse } from "./../../api/typesGenerated" +import { DAUsResponse } from "./../../api/typesGenerated" import { getDeploymentValues, getDeploymentDAUs } from "api/api" import { createMachine, assign } from "xstate" import { DeploymentConfig } from "api/types" @@ -12,7 +12,7 @@ export const deploymentConfigMachine = createMachine( context: {} as { deploymentValues?: DeploymentConfig getDeploymentValuesError?: unknown - deploymentDAUs?: DeploymentDAUsResponse + deploymentDAUs?: DAUsResponse getDeploymentDAUsError?: unknown }, events: {} as { type: "LOAD" }, @@ -21,7 +21,7 @@ export const deploymentConfigMachine = createMachine( data: DeploymentConfig } getDeploymentDAUs: { - data: DeploymentDAUsResponse + data: DAUsResponse } }, },