From ab9fa00499fe14df645be54534eadb5aa709b340 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 14 Oct 2022 20:17:22 +0000 Subject: [PATCH 1/9] Add template build time stats --- coderd/database/databasefake/databasefake.go | 29 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 35 ++++ coderd/database/queries/templates.sql | 23 +++ coderd/metricscache/metricscache.go | 43 ++++- coderd/metricscache/metricscache_test.go | 153 +++++++++++++++++- coderd/templates.go | 10 ++ coderd/templates_test.go | 4 +- codersdk/templates.go | 4 +- provisionerd/proto/provisionerd_drpc.pb.go | 2 +- provisionersdk/proto/provisioner_drpc.pb.go | 2 +- site/src/api/typesGenerated.ts | 1 + .../pages/TemplatesPage/TemplatesPageView.tsx | 6 + site/src/testHelpers/entities.ts | 1 + 14 files changed, 304 insertions(+), 10 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 654139e604ae2..8c52d3cb1e71c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -235,6 +235,35 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ( return rs, nil } +func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (float64, error) { + var times []float64 + for _, wb := range q.workspaceBuilds { + if wb.Transition != database.WorkspaceTransitionStart { + continue + } + version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID) + if err != nil { + return -1, err + } + if version.TemplateID != arg.TemplateID { + continue + } + + job, err := q.GetProvisionerJobByID(ctx, wb.JobID) + if err != nil { + return -1, err + } + if job.CompletedAt.Valid { + times = append(times, job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds()) + } + } + sort.Float64s(times) + if len(times) == 0 { + return -1, nil + } + return times[len(times)/2], nil +} + func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e9c577bcb11a7..673f0ec9bcaa2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -67,6 +67,7 @@ type sqlcQuerier interface { GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4b28c6004098a..ff008622b59e0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2587,6 +2587,41 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error return err } +const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at)) AS exec_time_sec +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = $1 AND + (workspace_builds.transition = 'start') AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > $2) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT CAST(PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) AS FLOAT) FROM build_times +` + +type GetTemplateAverageBuildTimeParams struct { + TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` + StartTime sql.NullTime `db:"start_time" json:"start_time"` +} + +func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) { + row := q.db.QueryRowContext(ctx, getTemplateAverageBuildTime, arg.TemplateID, arg.StartTime) + var column_1 float64 + err := row.Scan(&column_1) + return column_1, err +} + const getTemplateByID = `-- name: GetTemplateByID :one SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4d552443356fe..cb4c00c23fc07 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -105,3 +105,26 @@ WHERE id = $1 RETURNING *; + +-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at)) AS exec_time_sec +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = @template_id AND + (workspace_builds.transition = 'start') AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > @start_time) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT CAST(PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) AS FLOAT) FROM build_times +; diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index da38f54c840f0..42b8abe9c9d1d 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -2,6 +2,7 @@ package metricscache import ( "context" + "database/sql" "sync/atomic" "time" @@ -17,7 +18,7 @@ import ( "github.com/coder/retry" ) -// Cache holds the template DAU cache. +// 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 // take a few hundred milliseconds, which would ruin page load times and @@ -26,8 +27,9 @@ type Cache struct { database database.Store log slog.Logger - templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] - templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] + templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateAverageBuildTime atomic.Pointer[map[uuid.UUID]time.Duration] done chan struct{} cancel func() @@ -128,8 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error { } var ( - templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) - templateUniqueUsers = make(map[uuid.UUID]int) + templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) + templateUniqueUsers = make(map[uuid.UUID]int) + templateAverageBuildTimeSec = make(map[uuid.UUID]time.Duration) ) for _, template := range templates { rows, err := c.database.GetTemplateDAUs(ctx, template.ID) @@ -138,9 +141,24 @@ func (c *Cache) refresh(ctx context.Context) error { } templateDAUs[template.ID] = convertDAUResponse(rows) templateUniqueUsers[template.ID] = countUniqueUsers(rows) + avgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + StartTime: sql.NullTime{ + Time: database.Time(time.Now().AddDate(0, -30, 0)), + Valid: true, + }, + }) + if err != nil { + return err + } + templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * avgBuildTime) } c.templateDAUResponses.Store(&templateDAUs) c.templateUniqueUsers.Store(&templateUniqueUsers) + c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec) return nil } @@ -220,3 +238,18 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) { } return resp, true } + +func (c *Cache) TemplateAverageBuildTime(id uuid.UUID) (time.Duration, bool) { + m := c.templateAverageBuildTime.Load() + if m == nil { + // Data loading. + return -1, false + } + + resp, ok := (*m)[id] + if !ok || resp <= 0 { + // No data or not enough builds. + return -1, false + } + return resp, true +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 70d926702e77f..3ea2748331373 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -2,6 +2,7 @@ package metricscache_test import ( "context" + "database/sql" "testing" "time" @@ -20,7 +21,7 @@ func date(year, month, day int) time.Time { return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) } -func TestCache(t *testing.T) { +func TestCache_TemplateUsers(t *testing.T) { t.Parallel() var ( @@ -197,3 +198,153 @@ func TestCache(t *testing.T) { }) } } + +func clockTime(t time.Time, hour, minute, sec int) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location()) +} + +func TestCache_BuildTime(t *testing.T) { + t.Parallel() + + someDay := date(2022, 10, 1) + + type jobParams struct { + startedAt time.Time + completedAt time.Time + } + + type args struct { + rows []jobParams + } + type want struct { + buildTime time.Duration + } + tests := []struct { + name string + args args + want want + }{ + {"empty", args{}, want{-1}}, + {"one", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + }, + }, want{time.Second * 10}, + }, + {"two", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, + }, + }, want{time.Second * 50}, + }, + {"three", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 20), + }, + }, + }, want{time.Second * 20}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + var ( + db = databasefake.New() + cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast) + ) + + defer cache.Close() + + template, err := db.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: uuid.New(), + }) + require.NoError(t, err) + + templateVersion, err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: uuid.New(), + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + }) + require.NoError(t, err) + + gotBuildTime, ok := cache.TemplateAverageBuildTime(template.ID) + require.False(t, ok, "template shouldn't have loaded yet") + require.EqualValues(t, -1, gotBuildTime) + + for _, row := range tt.args.rows { + _, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + Provisioner: database.ProvisionerTypeEcho, + }) + require.NoError(t, err) + + job, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{Time: row.startedAt, Valid: true}, + Types: []database.ProvisionerType{ + database.ProvisionerTypeEcho, + }, + }) + require.NoError(t, err) + + _, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + TemplateVersionID: templateVersion.ID, + JobID: job.ID, + Transition: database.WorkspaceTransitionStart, + }) + require.NoError(t, err) + + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true}, + }) + require.NoError(t, err) + } + + if tt.want.buildTime > 0 { + require.Eventuallyf(t, func() bool { + _, ok := cache.TemplateAverageBuildTime(template.ID) + return ok + }, testutil.WaitShort, testutil.IntervalMedium, + "TemplateDAUs never populated", + ) + + gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) + require.True(t, ok) + require.Equal(t, tt.want.buildTime, gotBuildTime) + } else { + require.Never(t, func() bool { + _, ok := cache.TemplateAverageBuildTime(template.ID) + return ok + }, testutil.WaitShort/2, testutil.IntervalMedium, + "TemplateDAUs never populated", + ) + + gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) + require.False(t, ok) + require.Less(t, gotBuildTime, time.Duration(0)) + } + }) + } +} diff --git a/coderd/templates.go b/coderd/templates.go index fbc25a7b5546e..79fdc2091ffaf 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -773,6 +773,15 @@ func (api *API) convertTemplate( template database.Template, workspaceOwnerCount uint32, createdByName string, ) codersdk.Template { activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID) + + var averageBuildTimeMillis int64 + averageBuildTime, ok := api.metricsCache.TemplateAverageBuildTime(template.ID) + if !ok { + averageBuildTimeMillis = -1 + } else { + averageBuildTimeMillis = int64(averageBuildTime / time.Millisecond) + } + return codersdk.Template{ ID: template.ID, CreatedAt: template.CreatedAt, @@ -783,6 +792,7 @@ func (api *API) convertTemplate( ActiveVersionID: template.ActiveVersionID, WorkspaceOwnerCount: workspaceOwnerCount, ActiveUserCount: activeCount, + AverageBuildTimeMillis: averageBuildTimeMillis, Description: template.Description, Icon: template.Icon, MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(), diff --git a/coderd/templates_test.go b/coderd/templates_test.go index bf547c4d0eb9a..b384108387f07 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -561,7 +561,7 @@ func TestDeleteTemplate(t *testing.T) { }) } -func TestTemplateDAUs(t *testing.T) { +func TestTemplateMetrics(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ @@ -594,6 +594,7 @@ func TestTemplateDAUs(t *testing.T) { }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Equal(t, -1, template.ActiveUserCount) + require.EqualValues(t, -1, template.AverageBuildTimeMillis) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -660,6 +661,7 @@ func TestTemplateDAUs(t *testing.T) { template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, 1, template.ActiveUserCount) + require.Greater(t, template.AverageBuildTimeMillis, int64(1)) workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) diff --git a/codersdk/templates.go b/codersdk/templates.go index 7065fed2bdeba..887c648b42954 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,7 +23,9 @@ type Template struct { ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` + ActiveUserCount int `json:"active_user_count"` + // AverageBuildTimeMillis is set to -1 when there aren't enough recent builds. + AverageBuildTimeMillis int64 `json:"average_build_time_ms"` Description string `json:"description"` Icon string `json:"icon"` MaxTTLMillis int64 `json:"max_ttl_ms"` diff --git a/provisionerd/proto/provisionerd_drpc.pb.go b/provisionerd/proto/provisionerd_drpc.pb.go index 646f855eabc70..7ede95dcad75d 100644 --- a/provisionerd/proto/provisionerd_drpc.pb.go +++ b/provisionerd/proto/provisionerd_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.26 +// protoc-gen-go-drpc version: v0.0.33-0.20220923152156-858cfad9e41d // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index c990f6f645b7f..9931b1d9a3d33 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.26 +// protoc-gen-go-drpc version: v0.0.33-0.20220923152156-858cfad9e41d // source: provisionersdk/proto/provisioner.proto package proto diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0c9cf3792bb84..b0cde6f98dc37 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -586,6 +586,7 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly active_user_count: number + readonly average_build_time_sec: number readonly description: string readonly icon: string readonly max_ttl_ms: number diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index ac5ee78c8cdd7..33f6846bcdcd8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -14,6 +14,7 @@ import { Maybe } from "components/Conditionals/Maybe" import { FC } from "react" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" +import dayjs from "dayjs" import { createDayString } from "util/createDayString" import { formatTemplateActiveDevelopers } from "util/templates" import * as TypesGen from "../../api/typesGenerated" @@ -38,12 +39,17 @@ import { } from "../../components/Tooltips/HelpTooltip/HelpTooltip" export const Language = { + buildTime: (buildTimeSec: number): string => + buildTimeSec === -1 + ? "Unknown" + : dayjs.duration(buildTimeSec, "seconds").humanize(), developerCount: (activeCount: number): string => { return `${formatTemplateActiveDevelopers(activeCount)} developer${ activeCount !== 1 ? "s" : "" }` }, nameLabel: "Name", + buildTimeLabel: "Build time", usedByLabel: "Used by", lastUpdatedLabel: "Last updated", emptyViewNoPerms: diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 445696133d907..b0e51648a73d0 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -185,6 +185,7 @@ export const MockTemplate: TypesGen.Template = { active_version_id: MockTemplateVersion.id, workspace_owner_count: 2, active_user_count: 1, + average_build_time_sec: 123, description: "This is a test description.", max_ttl_ms: 24 * 60 * 60 * 1000, min_autostart_interval_ms: 60 * 60 * 1000, From c96b9a3c259c7f855a2ad50a7fd4f57bc594cfff Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 14 Oct 2022 21:06:46 +0000 Subject: [PATCH 2/9] Wire up frontend --- site/src/api/typesGenerated.ts | 2 +- .../TemplateStats/TemplateStats.tsx | 16 +++++++++++-- .../pages/TemplatesPage/TemplatesPageView.tsx | 23 +++++++++++++------ site/src/testHelpers/entities.ts | 2 +- site/src/util/templates.ts | 11 +++++++++ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b0cde6f98dc37..519b828d30980 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -586,7 +586,7 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly active_user_count: number - readonly average_build_time_sec: number + readonly average_build_time_ms: number readonly description: string readonly icon: string readonly max_ttl_ms: number diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index 6065c5a0ec775..dfdc0db40bdbe 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -1,12 +1,16 @@ import { makeStyles } from "@material-ui/core/styles" import { FC } from "react" import { createDayString } from "util/createDayString" -import { formatTemplateActiveDevelopers } from "util/templates" +import { + formatTemplateBuildTime, + formatTemplateActiveDevelopers, +} from "util/templates" import { Template, TemplateVersion } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" const Language = { usedByLabel: "Used by", + buildTimeLabel: "Build time", activeVersionLabel: "Active version", lastUpdateLabel: "Last updated", developerPlural: "developers", @@ -38,6 +42,14 @@ export const TemplateStats: FC = ({
+
+ {Language.buildTimeLabel} + + + {formatTemplateBuildTime(template.active_user_count)}{" "} + +
+
{Language.activeVersionLabel} {activeVersion.name} @@ -75,7 +87,7 @@ const useStyles = makeStyles((theme) => ({ }, statItem: { - minWidth: "20%", + minWidth: "15%", padding: theme.spacing(2), paddingTop: theme.spacing(1.75), }, diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 33f6846bcdcd8..b1904c58fe7f8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -14,9 +14,11 @@ import { Maybe } from "components/Conditionals/Maybe" import { FC } from "react" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" -import dayjs from "dayjs" import { createDayString } from "util/createDayString" -import { formatTemplateActiveDevelopers } from "util/templates" +import { + formatTemplateBuildTime, + formatTemplateActiveDevelopers, +} from "util/templates" import * as TypesGen from "../../api/typesGenerated" import { AvatarData } from "../../components/AvatarData/AvatarData" import { CodeExample } from "../../components/CodeExample/CodeExample" @@ -39,10 +41,6 @@ import { } from "../../components/Tooltips/HelpTooltip/HelpTooltip" export const Language = { - buildTime: (buildTimeSec: number): string => - buildTimeSec === -1 - ? "Unknown" - : dayjs.duration(buildTimeSec, "seconds").humanize(), developerCount: (activeCount: number): string => { return `${formatTemplateActiveDevelopers(activeCount)} developer${ activeCount !== 1 ? "s" : "" @@ -161,8 +159,9 @@ export const TemplatesPageView: FC< - {Language.nameLabel} + {Language.nameLabel} {Language.usedByLabel} + {Language.buildTimeLabel} {Language.lastUpdatedLabel} {Language.createdByLabel} @@ -233,6 +232,16 @@ export const TemplatesPageView: FC< + + + {formatTemplateBuildTime( + template.average_build_time_ms, + )} + + + { if (num === undefined || num < 0) { // Loading @@ -5,3 +10,9 @@ export const formatTemplateActiveDevelopers = (num?: number): string => { } return num.toString() } + +export const formatTemplateBuildTime = (buildTimeMs: number): string => { + return buildTimeMs < 0 + ? "Unknown" + : dayjs.duration(buildTimeMs, "milliseconds").humanize() +} From a8b81d58a70efa3189e9164909ca79befb70a037 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 14 Oct 2022 22:48:24 +0000 Subject: [PATCH 3/9] Progress bar! --- site/package.json | 1 + .../Workspace/Workspace.stories.tsx | 1 + site/src/components/Workspace/Workspace.tsx | 7 ++ .../WorkspaceBuildProgress.tsx | 94 +++++++++++++++++++ .../TemplatesPage/TemplatesPage.test.tsx | 3 + .../WorkspacePage/WorkspaceReadyPage.tsx | 1 + site/yarn.lock | 5 + 7 files changed, 112 insertions(+) create mode 100644 site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx diff --git a/site/package.json b/site/package.json index b38494f51ddf2..36f6ba7c353e5 100644 --- a/site/package.json +++ b/site/package.json @@ -115,6 +115,7 @@ "jest-junit": "14.0.0", "jest-runner-eslint": "1.1.0", "jest-websocket-mock": "2.4.0", + "mockdate": "^3.0.5", "msw": "0.47.0", "prettier": "2.7.1", "resize-observer": "1.0.4", diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index f8430ee947789..273bb3bdd1004 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -49,6 +49,7 @@ Running.args = { canUpdateWorkspace: true, workspaceErrors: {}, buildInfo: Mocks.MockBuildInfo, + template: Mocks.MockTemplate, } export const WithoutUpdateAccess = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 145858f6e24f2..6388b0ce12d23 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -20,6 +20,7 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" import { AlertBanner } from "../AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" +import { WorkspaceBuildProgress } from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" export enum WorkspaceErrors { GET_RESOURCES_ERROR = "getResourcesError", @@ -55,6 +56,7 @@ export interface WorkspaceProps { workspaceErrors: Partial> buildInfo?: TypesGen.BuildInfoResponse applicationsHost?: string + template?: TypesGen.Template } /** @@ -77,6 +79,7 @@ export const Workspace: FC> = ({ hideSSHButton, buildInfo, applicationsHost, + template, }) => { const { t } = useTranslation("workspacePage") const styles = useStyles() @@ -183,6 +186,10 @@ export const Workspace: FC> = ({ + {workspace.latest_build.status === "starting" && ( + + )} + {typeof resources !== "undefined" && resources.length > 0 && ( { + // Buffer the template average to prevent the progress bar from waiting at end. + // Over-promise, under-deliver. + templateAverage *= 1.2 + + const realPercentage = dayjs().diff(startedAt) / templateAverage + // Showing a full bar is frustrating. + const displayPercentage = Math.min(realPercentage, 0.95) + + if (realPercentage > 1) { + return [displayPercentage, "Any moment now..."] + } + + return [ + displayPercentage, + `${dayjs + .duration((1 - realPercentage) * templateAverage) + .humanize()} remaining...`, + ] +} + +export const WorkspaceBuildProgress: FC<{ + workspace: Workspace + template?: Template +}> = ({ workspace, template }) => { + const styles = useStyles() + + // Template stats not loaded or non-existent + if (!template || template.average_build_time_ms <= 0) { + return <> + } + + const job = workspace.latest_build.job + const status = job.status + + return ( +
+ +
+
{`Job ${status}`}
+
+ {status === "running" && + estimateFinish( + dayjs(job.started_at), + template.average_build_time_ms, + )[1]} +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + stack: { + paddingLeft: theme.spacing(0.2), + paddingRight: theme.spacing(0.2), + }, + barHelpers: { + display: "flex", + justifyContent: "space-between", + }, + label: { + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + color: theme.palette.text.secondary, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx index 16cffb1ff8f27..bdb95f34cde84 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -6,6 +6,9 @@ import { history, render } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import { TemplatesPage } from "./TemplatesPage" import { Language } from "./TemplatesPageView" +import MockDate from "mockdate" + +MockDate.set("2000-11-22") describe("TemplatesPage", () => { beforeEach(() => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index b695523a31327..e442a7d9b1964 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -132,6 +132,7 @@ export const WorkspaceReadyPage = ({ }} buildInfo={buildInfoState.context.buildInfo} applicationsHost={applicationsHost} + template={template} /> Date: Fri, 14 Oct 2022 22:56:40 +0000 Subject: [PATCH 4/9] Fix tests --- site/package.json | 1 - site/src/pages/TemplatesPage/TemplatesPage.test.tsx | 3 --- site/src/util/templates.ts | 2 ++ site/yarn.lock | 5 ----- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/site/package.json b/site/package.json index 36f6ba7c353e5..b38494f51ddf2 100644 --- a/site/package.json +++ b/site/package.json @@ -115,7 +115,6 @@ "jest-junit": "14.0.0", "jest-runner-eslint": "1.1.0", "jest-websocket-mock": "2.4.0", - "mockdate": "^3.0.5", "msw": "0.47.0", "prettier": "2.7.1", "resize-observer": "1.0.4", diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx index bdb95f34cde84..16cffb1ff8f27 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -6,9 +6,6 @@ import { history, render } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import { TemplatesPage } from "./TemplatesPage" import { Language } from "./TemplatesPageView" -import MockDate from "mockdate" - -MockDate.set("2000-11-22") describe("TemplatesPage", () => { beforeEach(() => { diff --git a/site/src/util/templates.ts b/site/src/util/templates.ts index 5ea5129f2e64f..efa9e0ccc0f76 100644 --- a/site/src/util/templates.ts +++ b/site/src/util/templates.ts @@ -1,7 +1,9 @@ import dayjs from "dayjs" import duration from "dayjs/plugin/duration" +import relativeTime from "dayjs/plugin/relativeTime" dayjs.extend(duration) +dayjs.extend(relativeTime) export const formatTemplateActiveDevelopers = (num?: number): string => { if (num === undefined || num < 0) { diff --git a/site/yarn.lock b/site/yarn.lock index 8c58d3df97964..69c339576ebb3 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -10195,11 +10195,6 @@ mock-socket@^9.1.0: resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.5.tgz#2c4e44922ad556843b6dfe09d14ed8041fa2cdeb" integrity sha512-3DeNIcsQixWHHKk6NdoBhWI4t1VMj5/HzfnI1rE/pLl5qKx7+gd4DNA07ehTaZ6MoUU053si6Hd+YtiM/tQZfg== -mockdate@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/mockdate/-/mockdate-3.0.5.tgz#789be686deb3149e7df2b663d2bc4392bc3284fb" - integrity sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ== - moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" From cbd778eae36f77399a61792e15daf42733e87bcc Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 19:27:45 +0000 Subject: [PATCH 5/9] fixup! Fix tests --- .../WorkspaceBuildProgress/WorkspaceBuildProgress.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx index 619ce256bf0a8..4b602f7ecadbe 100644 --- a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -58,7 +58,7 @@ export const WorkspaceBuildProgress: FC<{ )[0] * 100) || 0 } - variant="determinate" + variant={status === "running" ? "determinate" : "indeterminate"} />
{`Job ${status}`}
From 9a339bd2fba41e62ed53340e7a38837a5cbdaea1 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 19:35:21 +0000 Subject: [PATCH 6/9] fixup! Fix tests --- provisionerd/proto/provisionerd_drpc.pb.go | 2 +- provisionersdk/proto/provisioner_drpc.pb.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/provisionerd/proto/provisionerd_drpc.pb.go b/provisionerd/proto/provisionerd_drpc.pb.go index 7ede95dcad75d..646f855eabc70 100644 --- a/provisionerd/proto/provisionerd_drpc.pb.go +++ b/provisionerd/proto/provisionerd_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.33-0.20220923152156-858cfad9e41d +// protoc-gen-go-drpc version: v0.0.26 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index 9931b1d9a3d33..c990f6f645b7f 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: v0.0.33-0.20220923152156-858cfad9e41d +// protoc-gen-go-drpc version: v0.0.26 // source: provisionersdk/proto/provisioner.proto package proto From 6c7d5fca44ff46236dee5bf5f4486412aec2f71c Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 19:52:15 +0000 Subject: [PATCH 7/9] Fix DB null handling --- coderd/database/queries.sql.go | 5 +++-- coderd/database/queries/templates.sql | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff008622b59e0..68a1987849fbd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2590,7 +2590,7 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT - EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at)) AS exec_time_sec + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec FROM workspace_builds JOIN template_versions ON @@ -2607,7 +2607,8 @@ WHERE ORDER BY workspace_builds.created_at DESC ) -SELECT CAST(PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) AS FLOAT) FROM build_times +SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +FROM build_times ` type GetTemplateAverageBuildTimeParams struct { diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index cb4c00c23fc07..2bb6f933eeae5 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -109,7 +109,7 @@ RETURNING -- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT - EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at)) AS exec_time_sec + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec FROM workspace_builds JOIN template_versions ON @@ -126,5 +126,6 @@ WHERE ORDER BY workspace_builds.created_at DESC ) -SELECT CAST(PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) AS FLOAT) FROM build_times +SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +FROM build_times ; From ab585455000c283c8a5c735d294dbf546da43ff2 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 19:57:48 +0000 Subject: [PATCH 8/9] fixup! Fix DB null handling --- coderd/metricscache/metricscache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index 42b8abe9c9d1d..1feb36ba2345b 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -141,7 +141,7 @@ func (c *Cache) refresh(ctx context.Context) error { } templateDAUs[template.ID] = convertDAUResponse(rows) templateUniqueUsers[template.ID] = countUniqueUsers(rows) - avgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ + templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ TemplateID: uuid.NullUUID{ UUID: template.ID, Valid: true, @@ -154,7 +154,7 @@ func (c *Cache) refresh(ctx context.Context) error { if err != nil { return err } - templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * avgBuildTime) + templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * templateAvgBuildTime) } c.templateDAUResponses.Store(&templateDAUs) c.templateUniqueUsers.Store(&templateUniqueUsers) From dc191340b9582c5400a2aa31e8feff4647c530bb Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 20:00:35 +0000 Subject: [PATCH 9/9] Job -> Build --- .../WorkspaceBuildProgress/WorkspaceBuildProgress.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx index 4b602f7ecadbe..3d51f5d20cf28 100644 --- a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -61,7 +61,7 @@ export const WorkspaceBuildProgress: FC<{ variant={status === "running" ? "determinate" : "indeterminate"} />
-
{`Job ${status}`}
+
{`Build ${status}`}
{status === "running" && estimateFinish(