mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Add template build time stats (#4557)
- Expose time in Template pages - Show progress bar when building a workspace
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2597,6 +2597,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
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TemplateStatsProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statsLabel}>{Language.buildTimeLabel}</span>
|
||||
|
||||
<span className={styles.statsValue}>
|
||||
{formatTemplateBuildTime(template.active_user_count)}{" "}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statsLabel}>{Language.activeVersionLabel}</span>
|
||||
<span className={styles.statsValue}>{activeVersion.name}</span>
|
||||
@@ -75,7 +87,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
|
||||
statItem: {
|
||||
minWidth: "20%",
|
||||
minWidth: "15%",
|
||||
padding: theme.spacing(2),
|
||||
paddingTop: theme.spacing(1.75),
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ Running.args = {
|
||||
canUpdateWorkspace: true,
|
||||
workspaceErrors: {},
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
template: Mocks.MockTemplate,
|
||||
}
|
||||
|
||||
export const WithoutUpdateAccess = Template.bind({})
|
||||
|
||||
@@ -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<Record<WorkspaceErrors, Error | unknown>>
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
applicationsHost?: string
|
||||
template?: TypesGen.Template
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
hideSSHButton,
|
||||
buildInfo,
|
||||
applicationsHost,
|
||||
template,
|
||||
}) => {
|
||||
const { t } = useTranslation("workspacePage")
|
||||
const styles = useStyles()
|
||||
@@ -183,6 +186,10 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
|
||||
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
|
||||
|
||||
{workspace.latest_build.status === "starting" && (
|
||||
<WorkspaceBuildProgress workspace={workspace} template={template} />
|
||||
)}
|
||||
|
||||
{typeof resources !== "undefined" && resources.length > 0 && (
|
||||
<Resources
|
||||
resources={resources}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import LinearProgress from "@material-ui/core/LinearProgress"
|
||||
import makeStyles from "@material-ui/core/styles/makeStyles"
|
||||
import { Template, Workspace } from "api/typesGenerated"
|
||||
import dayjs, { Dayjs } from "dayjs"
|
||||
import { FC } from "react"
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
|
||||
|
||||
import duration from "dayjs/plugin/duration"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
const estimateFinish = (
|
||||
startedAt: Dayjs,
|
||||
templateAverage: number,
|
||||
): [number, string] => {
|
||||
// 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 (
|
||||
<div className={styles.stack}>
|
||||
<LinearProgress
|
||||
value={
|
||||
(status === "running" &&
|
||||
estimateFinish(
|
||||
dayjs(job.started_at),
|
||||
template.average_build_time_ms,
|
||||
)[0] * 100) ||
|
||||
0
|
||||
}
|
||||
variant={status === "running" ? "determinate" : "indeterminate"}
|
||||
/>
|
||||
<div className={styles.barHelpers}>
|
||||
<div className={styles.label}>{`Build ${status}`}</div>
|
||||
<div className={styles.label}>
|
||||
{status === "running" &&
|
||||
estimateFinish(
|
||||
dayjs(job.started_at),
|
||||
template.average_build_time_ms,
|
||||
)[1]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}))
|
||||
@@ -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<
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50%">{Language.nameLabel}</TableCell>
|
||||
<TableCell width="34%">{Language.nameLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.usedByLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.buildTimeLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.lastUpdatedLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.createdByLabel}</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
@@ -227,6 +232,16 @@ export const TemplatesPageView: FC<
|
||||
</span>
|
||||
</TableCellLink>
|
||||
|
||||
<TableCellLink to={templatePageLink}>
|
||||
<span
|
||||
style={{ color: theme.palette.text.secondary }}
|
||||
>
|
||||
{formatTemplateBuildTime(
|
||||
template.average_build_time_ms,
|
||||
)}
|
||||
</span>
|
||||
</TableCellLink>
|
||||
|
||||
<TableCellLink
|
||||
data-chromatic="ignore"
|
||||
to={templatePageLink}
|
||||
|
||||
@@ -132,6 +132,7 @@ export const WorkspaceReadyPage = ({
|
||||
}}
|
||||
buildInfo={buildInfoState.context.buildInfo}
|
||||
applicationsHost={applicationsHost}
|
||||
template={template}
|
||||
/>
|
||||
<DeleteDialog
|
||||
entity="workspace"
|
||||
|
||||
@@ -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_ms: 123,
|
||||
description: "This is a test description.",
|
||||
max_ttl_ms: 24 * 60 * 60 * 1000,
|
||||
min_autostart_interval_ms: 60 * 60 * 1000,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
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) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user