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 } }, }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy