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..68a1987849fbd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2587,6 +2587,42 @@ 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))::FLOAT 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 coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::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..2bb6f933eeae5 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -105,3 +105,27 @@ WHERE id = $1 RETURNING *; + +-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT 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 coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +FROM build_times +; diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index da38f54c840f0..1feb36ba2345b 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) + templateAvgBuildTime, 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) * templateAvgBuildTime) } 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0c9cf3792bb84..519b828d30980 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_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/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 ( +
+ +
+
{`Build ${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/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index ac5ee78c8cdd7..b1904c58fe7f8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -15,7 +15,10 @@ import { FC } from "react" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" 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" @@ -44,6 +47,7 @@ export const Language = { }` }, nameLabel: "Name", + buildTimeLabel: "Build time", usedByLabel: "Used by", lastUpdatedLabel: "Last updated", emptyViewNoPerms: @@ -155,8 +159,9 @@ export const TemplatesPageView: FC< - {Language.nameLabel} + {Language.nameLabel} {Language.usedByLabel} + {Language.buildTimeLabel} {Language.lastUpdatedLabel} {Language.createdByLabel} @@ -227,6 +232,16 @@ export const TemplatesPageView: FC< + + + {formatTemplateBuildTime( + template.average_build_time_ms, + )} + + + { if (num === undefined || num < 0) { // Loading @@ -5,3 +12,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() +}