mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: use active users instead of total users in Template views (#3900)
This commit is contained in:
@@ -41,6 +41,7 @@ site/out/
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
.vscode/launch.json
|
||||
**/*.swp
|
||||
.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
Vendored
+4
-10
@@ -161,16 +161,10 @@
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
// Since package coverage pairing can't be defined, all packages cover
|
||||
// all other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredHighlightColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredHighlightColor": "rgba(128,64,64,0.25)",
|
||||
"coveredBorderColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredBorderColor": "rgba(128,64,64,0.25)",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
"go.testFlags": [
|
||||
"-short",
|
||||
"-coverpkg=./..."
|
||||
],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib"
|
||||
|
||||
+8
-8
@@ -72,7 +72,7 @@ func create() *cobra.Command {
|
||||
}
|
||||
|
||||
slices.SortFunc(templates, func(a, b codersdk.Template) bool {
|
||||
return a.WorkspaceOwnerCount > b.WorkspaceOwnerCount
|
||||
return a.ActiveUserCount > b.ActiveUserCount
|
||||
})
|
||||
|
||||
templateNames := make([]string, 0, len(templates))
|
||||
@@ -81,13 +81,13 @@ func create() *cobra.Command {
|
||||
for _, template := range templates {
|
||||
templateName := template.Name
|
||||
|
||||
if template.WorkspaceOwnerCount > 0 {
|
||||
developerText := "developer"
|
||||
if template.WorkspaceOwnerCount != 1 {
|
||||
developerText = "developers"
|
||||
}
|
||||
|
||||
templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText))
|
||||
if template.ActiveUserCount > 0 {
|
||||
templateName += cliui.Styles.Placeholder.Render(
|
||||
fmt.Sprintf(
|
||||
" (used by %s)",
|
||||
formatActiveDevelopers(template.ActiveUserCount),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
templateNames = append(templateNames, templateName)
|
||||
|
||||
+1
-7
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -64,11 +63,6 @@ type templateTableRow struct {
|
||||
func displayTemplates(filterColumns []string, templates ...codersdk.Template) (string, error) {
|
||||
rows := make([]templateTableRow, len(templates))
|
||||
for i, template := range templates {
|
||||
suffix := ""
|
||||
if template.WorkspaceOwnerCount != 1 {
|
||||
suffix = "s"
|
||||
}
|
||||
|
||||
rows[i] = templateTableRow{
|
||||
Name: template.Name,
|
||||
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
|
||||
@@ -76,7 +70,7 @@ func displayTemplates(filterColumns []string, templates ...codersdk.Template) (s
|
||||
OrganizationID: template.OrganizationID,
|
||||
Provisioner: template.Provisioner,
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
UsedBy: cliui.Styles.Fuchsia.Render(fmt.Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)),
|
||||
UsedBy: cliui.Styles.Fuchsia.Render(formatActiveDevelopers(template.ActiveUserCount)),
|
||||
MaxTTL: (time.Duration(template.MaxTTLMillis) * time.Millisecond),
|
||||
MinAutostartInterval: (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond),
|
||||
}
|
||||
|
||||
+17
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -175,3 +176,19 @@ func parseTime(s string) (time.Time, error) {
|
||||
}
|
||||
return time.Time{}, errInvalidTimeFormat
|
||||
}
|
||||
|
||||
func formatActiveDevelopers(n int) string {
|
||||
developerText := "developer"
|
||||
if n != 1 {
|
||||
developerText = "developers"
|
||||
}
|
||||
|
||||
var nStr string
|
||||
if n < 0 {
|
||||
nStr = "-"
|
||||
} else {
|
||||
nStr = strconv.Itoa(n)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s active %s", nStr, developerText)
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
counts := make(map[time.Time]map[string]struct{})
|
||||
seens := make(map[time.Time]map[uuid.UUID]struct{})
|
||||
|
||||
for _, as := range q.agentStats {
|
||||
if as.TemplateID != templateID {
|
||||
@@ -171,26 +171,29 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
|
||||
}
|
||||
|
||||
date := as.CreatedAt.Truncate(time.Hour * 24)
|
||||
dateEntry := counts[date]
|
||||
if dateEntry == nil {
|
||||
dateEntry = make(map[string]struct{})
|
||||
}
|
||||
counts[date] = dateEntry
|
||||
|
||||
dateEntry[as.UserID.String()] = struct{}{}
|
||||
dateEntry := seens[date]
|
||||
if dateEntry == nil {
|
||||
dateEntry = make(map[uuid.UUID]struct{})
|
||||
}
|
||||
dateEntry[as.UserID] = struct{}{}
|
||||
seens[date] = dateEntry
|
||||
}
|
||||
|
||||
countKeys := maps.Keys(counts)
|
||||
sort.Slice(countKeys, func(i, j int) bool {
|
||||
return countKeys[i].Before(countKeys[j])
|
||||
seenKeys := maps.Keys(seens)
|
||||
sort.Slice(seenKeys, func(i, j int) bool {
|
||||
return seenKeys[i].Before(seenKeys[j])
|
||||
})
|
||||
|
||||
var rs []database.GetTemplateDAUsRow
|
||||
for _, key := range countKeys {
|
||||
rs = append(rs, database.GetTemplateDAUsRow{
|
||||
Date: key,
|
||||
Amount: int64(len(counts[key])),
|
||||
})
|
||||
for _, key := range seenKeys {
|
||||
ids := seens[key]
|
||||
for id := range ids {
|
||||
rs = append(rs, database.GetTemplateDAUsRow{
|
||||
Date: key,
|
||||
UserID: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
|
||||
@@ -27,19 +27,19 @@ func (q *sqlQuerier) DeleteOldAgentStats(ctx context.Context) error {
|
||||
const getTemplateDAUs = `-- name: GetTemplateDAUs :many
|
||||
select
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
count(distinct(user_id)) as amount
|
||||
user_id
|
||||
from
|
||||
agent_stats
|
||||
where template_id = $1
|
||||
group by
|
||||
date
|
||||
date, user_id
|
||||
order by
|
||||
date asc
|
||||
`
|
||||
|
||||
type GetTemplateDAUsRow struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
Amount int64 `db:"amount" json:"amount"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) {
|
||||
@@ -51,7 +51,7 @@ func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID)
|
||||
var items []GetTemplateDAUsRow
|
||||
for rows.Next() {
|
||||
var i GetTemplateDAUsRow
|
||||
if err := rows.Scan(&i.Date, &i.Amount); err != nil {
|
||||
if err := rows.Scan(&i.Date, &i.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
||||
@@ -15,12 +15,12 @@ VALUES
|
||||
-- name: GetTemplateDAUs :many
|
||||
select
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
count(distinct(user_id)) as amount
|
||||
user_id
|
||||
from
|
||||
agent_stats
|
||||
where template_id = $1
|
||||
group by
|
||||
date
|
||||
date, user_id
|
||||
order by
|
||||
date asc;
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -24,9 +26,10 @@ type Cache struct {
|
||||
database database.Store
|
||||
log slog.Logger
|
||||
|
||||
templateDAUResponses atomic.Pointer[map[string]codersdk.TemplateDAUsResponse]
|
||||
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
|
||||
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
|
||||
|
||||
doneCh chan struct{}
|
||||
done chan struct{}
|
||||
cancel func()
|
||||
|
||||
interval time.Duration
|
||||
@@ -41,7 +44,7 @@ func New(db database.Store, log slog.Logger, interval time.Duration) *Cache {
|
||||
c := &Cache{
|
||||
database: db,
|
||||
log: log,
|
||||
doneCh: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
cancel: cancel,
|
||||
interval: interval,
|
||||
}
|
||||
@@ -49,34 +52,68 @@ func New(db database.Store, log slog.Logger, interval time.Duration) *Cache {
|
||||
return c
|
||||
}
|
||||
|
||||
func fillEmptyDays(rows []database.GetTemplateDAUsRow) []database.GetTemplateDAUsRow {
|
||||
var newRows []database.GetTemplateDAUsRow
|
||||
func fillEmptyDays(sortedDates []time.Time) []time.Time {
|
||||
var newDates []time.Time
|
||||
|
||||
for i, row := range rows {
|
||||
for i, ti := range sortedDates {
|
||||
if i == 0 {
|
||||
newRows = append(newRows, row)
|
||||
newDates = append(newDates, ti)
|
||||
continue
|
||||
}
|
||||
|
||||
last := rows[i-1]
|
||||
last := sortedDates[i-1]
|
||||
|
||||
const day = time.Hour * 24
|
||||
diff := row.Date.Sub(last.Date)
|
||||
diff := ti.Sub(last)
|
||||
for diff > day {
|
||||
if diff <= day {
|
||||
break
|
||||
}
|
||||
last.Date = last.Date.Add(day)
|
||||
last.Amount = 0
|
||||
newRows = append(newRows, last)
|
||||
last = last.Add(day)
|
||||
newDates = append(newDates, last)
|
||||
diff -= day
|
||||
}
|
||||
|
||||
newRows = append(newRows, row)
|
||||
newDates = append(newDates, ti)
|
||||
continue
|
||||
}
|
||||
|
||||
return newRows
|
||||
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
|
||||
}
|
||||
|
||||
func countUniqueUsers(rows []database.GetTemplateDAUsRow) int {
|
||||
seen := make(map[uuid.UUID]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
seen[row.UserID] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func (c *Cache) refresh(ctx context.Context) error {
|
||||
@@ -90,30 +127,26 @@ func (c *Cache) refresh(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
templateDAUs := make(map[string]codersdk.TemplateDAUsResponse, len(templates))
|
||||
|
||||
var (
|
||||
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
|
||||
templateUniqueUsers = make(map[uuid.UUID]int)
|
||||
)
|
||||
for _, template := range templates {
|
||||
daus, err := c.database.GetTemplateDAUs(ctx, template.ID)
|
||||
rows, err := c.database.GetTemplateDAUs(ctx, template.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp codersdk.TemplateDAUsResponse
|
||||
for _, ent := range fillEmptyDays(daus) {
|
||||
resp.Entries = append(resp.Entries, codersdk.DAUEntry{
|
||||
Date: ent.Date,
|
||||
Amount: int(ent.Amount),
|
||||
})
|
||||
}
|
||||
templateDAUs[template.ID.String()] = resp
|
||||
templateDAUs[template.ID] = convertDAUResponse(rows)
|
||||
templateUniqueUsers[template.ID] = countUniqueUsers(rows)
|
||||
}
|
||||
|
||||
c.templateDAUResponses.Store(&templateDAUs)
|
||||
c.templateUniqueUsers.Store(&templateUniqueUsers)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) run(ctx context.Context) {
|
||||
defer close(c.doneCh)
|
||||
defer close(c.done)
|
||||
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
@@ -140,7 +173,7 @@ func (c *Cache) run(ctx context.Context) {
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-c.doneCh:
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -150,23 +183,40 @@ func (c *Cache) run(ctx context.Context) {
|
||||
|
||||
func (c *Cache) Close() error {
|
||||
c.cancel()
|
||||
<-c.doneCh
|
||||
<-c.done
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (c *Cache) TemplateDAUs(id uuid.UUID) (*codersdk.TemplateDAUsResponse, bool) {
|
||||
m := c.templateDAUResponses.Load()
|
||||
if m == nil {
|
||||
// Data loading.
|
||||
return codersdk.TemplateDAUsResponse{}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
resp, ok := (*m)[id.String()]
|
||||
resp, ok := (*m)[id]
|
||||
if !ok {
|
||||
// Probably no data.
|
||||
return codersdk.TemplateDAUsResponse{}
|
||||
return nil, false
|
||||
}
|
||||
return resp
|
||||
return &resp, true
|
||||
}
|
||||
|
||||
// TemplateUniqueUsers returns the number of unique Template users
|
||||
// from all Cache data.
|
||||
func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) {
|
||||
m := c.templateUniqueUsers.Load()
|
||||
if m == nil {
|
||||
// Data loading.
|
||||
return -1, false
|
||||
}
|
||||
|
||||
resp, ok := (*m)[id]
|
||||
if !ok {
|
||||
// Probably no data.
|
||||
return -1, false
|
||||
}
|
||||
return resp, true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package metricscache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,19 +24,23 @@ func TestCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
zebra = uuid.New()
|
||||
tiger = uuid.New()
|
||||
zebra = uuid.UUID{1}
|
||||
tiger = uuid.UUID{2}
|
||||
)
|
||||
|
||||
type args struct {
|
||||
rows []database.InsertAgentStatParams
|
||||
}
|
||||
type want struct {
|
||||
entries []codersdk.DAUEntry
|
||||
uniqueUsers int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []codersdk.DAUEntry
|
||||
want want
|
||||
}{
|
||||
{"empty", args{}, nil},
|
||||
{"empty", args{}, want{nil, 0}},
|
||||
{"one hole", args{
|
||||
rows: []database.InsertAgentStatParams{
|
||||
{
|
||||
@@ -49,7 +52,7 @@ func TestCache(t *testing.T) {
|
||||
UserID: zebra,
|
||||
},
|
||||
},
|
||||
}, []codersdk.DAUEntry{
|
||||
}, want{[]codersdk.DAUEntry{
|
||||
{
|
||||
Date: date(2022, 8, 27),
|
||||
Amount: 1,
|
||||
@@ -66,7 +69,8 @@ func TestCache(t *testing.T) {
|
||||
Date: date(2022, 8, 30),
|
||||
Amount: 1,
|
||||
},
|
||||
}},
|
||||
}, 1},
|
||||
},
|
||||
{"no holes", args{
|
||||
rows: []database.InsertAgentStatParams{
|
||||
{
|
||||
@@ -82,7 +86,7 @@ func TestCache(t *testing.T) {
|
||||
UserID: zebra,
|
||||
},
|
||||
},
|
||||
}, []codersdk.DAUEntry{
|
||||
}, want{[]codersdk.DAUEntry{
|
||||
{
|
||||
Date: date(2022, 8, 27),
|
||||
Amount: 1,
|
||||
@@ -95,7 +99,7 @@ func TestCache(t *testing.T) {
|
||||
Date: date(2022, 8, 29),
|
||||
Amount: 1,
|
||||
},
|
||||
}},
|
||||
}, 1}},
|
||||
{"holes", args{
|
||||
rows: []database.InsertAgentStatParams{
|
||||
{
|
||||
@@ -119,7 +123,7 @@ func TestCache(t *testing.T) {
|
||||
UserID: tiger,
|
||||
},
|
||||
},
|
||||
}, []codersdk.DAUEntry{
|
||||
}, want{[]codersdk.DAUEntry{
|
||||
{
|
||||
Date: date(2022, 1, 1),
|
||||
Amount: 2,
|
||||
@@ -148,7 +152,7 @@ func TestCache(t *testing.T) {
|
||||
Date: date(2022, 1, 7),
|
||||
Amount: 2,
|
||||
},
|
||||
}},
|
||||
}, 2}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -157,7 +161,7 @@ func TestCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = databasefake.New()
|
||||
cache = metricscache.New(db, slogtest.Make(t, nil), time.Millisecond*100)
|
||||
cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast)
|
||||
)
|
||||
|
||||
defer cache.Close()
|
||||
@@ -167,19 +171,29 @@ func TestCache(t *testing.T) {
|
||||
ID: templateID,
|
||||
})
|
||||
|
||||
gotUniqueUsers, ok := cache.TemplateUniqueUsers(templateID)
|
||||
require.False(t, ok, "template shouldn't have loaded yet")
|
||||
require.EqualValues(t, -1, gotUniqueUsers)
|
||||
|
||||
for _, row := range tt.args.rows {
|
||||
row.TemplateID = templateID
|
||||
db.InsertAgentStat(context.Background(), row)
|
||||
}
|
||||
|
||||
var got codersdk.TemplateDAUsResponse
|
||||
|
||||
require.Eventuallyf(t, func() bool {
|
||||
got = cache.TemplateDAUs(templateID)
|
||||
return reflect.DeepEqual(got.Entries, tt.want)
|
||||
}, testutil.WaitShort, testutil.IntervalFast,
|
||||
"GetDAUs() = %v, want %v", got, tt.want,
|
||||
_, ok := cache.TemplateDAUs(templateID)
|
||||
return ok
|
||||
}, testutil.WaitShort, testutil.IntervalMedium,
|
||||
"TemplateDAUs never populated",
|
||||
)
|
||||
|
||||
gotUniqueUsers, ok = cache.TemplateUniqueUsers(templateID)
|
||||
require.True(t, ok)
|
||||
|
||||
gotEntries, ok := cache.TemplateDAUs(templateID)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tt.want.entries, gotEntries.Entries)
|
||||
require.Equal(t, tt.want.uniqueUsers, gotUniqueUsers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+21
-14
@@ -79,7 +79,7 @@ func (api *API) template(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, convertTemplate(template, count, createdByNameMap[template.ID.String()]))
|
||||
httpapi.Write(rw, http.StatusOK, api.convertTemplate(template, count, createdByNameMap[template.ID.String()]))
|
||||
}
|
||||
|
||||
func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -304,7 +304,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
||||
return xerrors.Errorf("get creator name: %w", err)
|
||||
}
|
||||
|
||||
template = convertTemplate(dbTemplate, 0, createdByNameMap[dbTemplate.ID.String()])
|
||||
template = api.convertTemplate(dbTemplate, 0, createdByNameMap[dbTemplate.ID.String()])
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -375,7 +375,7 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, convertTemplates(templates, workspaceCounts, createdByNameMap))
|
||||
httpapi.Write(rw, http.StatusOK, api.convertTemplates(templates, workspaceCounts, createdByNameMap))
|
||||
}
|
||||
|
||||
func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -429,7 +429,7 @@ func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, convertTemplate(template, count, createdByNameMap[template.ID.String()]))
|
||||
httpapi.Write(rw, http.StatusOK, api.convertTemplate(template, count, createdByNameMap[template.ID.String()]))
|
||||
}
|
||||
|
||||
func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -557,7 +557,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, convertTemplate(updated, count, createdByNameMap[updated.ID.String()]))
|
||||
httpapi.Write(rw, http.StatusOK, api.convertTemplate(updated, count, createdByNameMap[updated.ID.String()]))
|
||||
}
|
||||
|
||||
func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -567,9 +567,12 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := api.metricsCache.TemplateDAUs(template.ID)
|
||||
if resp.Entries == nil {
|
||||
resp.Entries = []codersdk.DAUEntry{}
|
||||
resp, _ := api.metricsCache.TemplateDAUs(template.ID)
|
||||
if resp == nil || resp.Entries == nil {
|
||||
httpapi.Write(rw, http.StatusOK, &codersdk.TemplateDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{},
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(rw, http.StatusOK, resp)
|
||||
}
|
||||
@@ -726,7 +729,7 @@ func getCreatedByNamesByTemplateIDs(ctx context.Context, db database.Store, temp
|
||||
return creators, nil
|
||||
}
|
||||
|
||||
func convertTemplates(templates []database.Template, workspaceCounts []database.GetWorkspaceOwnerCountsByTemplateIDsRow, createdByNameMap map[string]string) []codersdk.Template {
|
||||
func (api *API) convertTemplates(templates []database.Template, workspaceCounts []database.GetWorkspaceOwnerCountsByTemplateIDsRow, createdByNameMap map[string]string) []codersdk.Template {
|
||||
apiTemplates := make([]codersdk.Template, 0, len(templates))
|
||||
|
||||
for _, template := range templates {
|
||||
@@ -735,24 +738,27 @@ func convertTemplates(templates []database.Template, workspaceCounts []database.
|
||||
if workspaceCount.TemplateID.String() != template.ID.String() {
|
||||
continue
|
||||
}
|
||||
apiTemplates = append(apiTemplates, convertTemplate(template, uint32(workspaceCount.Count), createdByNameMap[template.ID.String()]))
|
||||
apiTemplates = append(apiTemplates, api.convertTemplate(template, uint32(workspaceCount.Count), createdByNameMap[template.ID.String()]))
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
apiTemplates = append(apiTemplates, convertTemplate(template, uint32(0), createdByNameMap[template.ID.String()]))
|
||||
apiTemplates = append(apiTemplates, api.convertTemplate(template, uint32(0), createdByNameMap[template.ID.String()]))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort templates by WorkspaceOwnerCount DESC
|
||||
// Sort templates by ActiveUserCount DESC
|
||||
sort.SliceStable(apiTemplates, func(i, j int) bool {
|
||||
return apiTemplates[i].WorkspaceOwnerCount > apiTemplates[j].WorkspaceOwnerCount
|
||||
return apiTemplates[i].ActiveUserCount > apiTemplates[j].ActiveUserCount
|
||||
})
|
||||
|
||||
return apiTemplates
|
||||
}
|
||||
|
||||
func convertTemplate(template database.Template, workspaceOwnerCount uint32, createdByName string) codersdk.Template {
|
||||
func (api *API) convertTemplate(
|
||||
template database.Template, workspaceOwnerCount uint32, createdByName string,
|
||||
) codersdk.Template {
|
||||
activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID)
|
||||
return codersdk.Template{
|
||||
ID: template.ID,
|
||||
CreatedAt: template.CreatedAt,
|
||||
@@ -762,6 +768,7 @@ func convertTemplate(template database.Template, workspaceOwnerCount uint32, cre
|
||||
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
WorkspaceOwnerCount: workspaceOwnerCount,
|
||||
ActiveUserCount: activeCount,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(),
|
||||
|
||||
@@ -593,6 +593,8 @@ func TestTemplateDAUs(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Equal(t, -1, template.ActiveUserCount)
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
@@ -635,7 +637,7 @@ func TestTemplateDAUs(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_ = sshConn.Close()
|
||||
|
||||
want := &codersdk.TemplateDAUsResponse{
|
||||
wantDAUs := &codersdk.TemplateDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{
|
||||
{
|
||||
|
||||
@@ -647,12 +649,18 @@ func TestTemplateDAUs(t *testing.T) {
|
||||
require.Eventuallyf(t, func() bool {
|
||||
daus, err = client.TemplateDAUs(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return assert.ObjectsAreEqual(want, daus)
|
||||
return len(daus.Entries) > 0
|
||||
},
|
||||
testutil.WaitShort, testutil.IntervalFast,
|
||||
"got %+v != %+v", daus, want,
|
||||
"template daus never loaded",
|
||||
)
|
||||
gotDAUs, err := client.TemplateDAUs(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, gotDAUs, wantDAUs)
|
||||
|
||||
template, err = client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, template.ActiveUserCount)
|
||||
|
||||
workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
|
||||
+16
-14
@@ -14,20 +14,22 @@ import (
|
||||
// Template is the JSON representation of a Coder template. This type matches the
|
||||
// database object for now, but is abstracted for ease of change later on.
|
||||
type Template struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Provisioner ProvisionerType `json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `json:"active_version_id"`
|
||||
WorkspaceOwnerCount uint32 `json:"workspace_owner_count"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
MaxTTLMillis int64 `json:"max_ttl_ms"`
|
||||
MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"`
|
||||
CreatedByID uuid.UUID `json:"created_by_id"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Provisioner ProvisionerType `json:"provisioner"`
|
||||
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"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
MaxTTLMillis int64 `json:"max_ttl_ms"`
|
||||
MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"`
|
||||
CreatedByID uuid.UUID `json:"created_by_id"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
}
|
||||
|
||||
type UpdateActiveTemplateVersion struct {
|
||||
|
||||
@@ -378,6 +378,7 @@ export interface Template {
|
||||
readonly provisioner: ProvisionerType
|
||||
readonly active_version_id: string
|
||||
readonly workspace_owner_count: number
|
||||
readonly active_user_count: number
|
||||
readonly description: string
|
||||
readonly icon: string
|
||||
readonly max_ttl_ms: number
|
||||
|
||||
@@ -19,7 +19,16 @@ export const UsedByMany = Template.bind({})
|
||||
UsedByMany.args = {
|
||||
template: {
|
||||
...Mocks.MockTemplate,
|
||||
workspace_owner_count: 15,
|
||||
active_user_count: 15,
|
||||
},
|
||||
activeVersion: Mocks.MockTemplateVersion,
|
||||
}
|
||||
|
||||
export const ActiveUsersNotLoaded = Template.bind({})
|
||||
ActiveUsersNotLoaded.args = {
|
||||
template: {
|
||||
...Mocks.MockTemplate,
|
||||
active_user_count: -1,
|
||||
},
|
||||
activeVersion: Mocks.MockTemplateVersion,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { FC } from "react"
|
||||
import { createDayString } from "util/createDayString"
|
||||
import { formatTemplateActiveDevelopers } from "util/templates"
|
||||
import { Template, TemplateVersion } from "../../api/typesGenerated"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
|
||||
@@ -27,10 +28,8 @@ export const TemplateStats: FC<TemplateStatsProps> = ({ template, activeVersion
|
||||
<span className={styles.statsLabel}>{Language.usedByLabel}</span>
|
||||
|
||||
<span className={styles.statsValue}>
|
||||
{template.workspace_owner_count}{" "}
|
||||
{template.workspace_owner_count === 1
|
||||
? Language.developerSingular
|
||||
: Language.developerPlural}
|
||||
{formatTemplateActiveDevelopers(template.active_user_count)}{" "}
|
||||
{template.active_user_count === 1 ? Language.developerSingular : Language.developerPlural}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
|
||||
@@ -16,12 +16,13 @@ AllStates.args = {
|
||||
MockTemplate,
|
||||
{
|
||||
...MockTemplate,
|
||||
description: "🚀 Some magical template that does some magical things!",
|
||||
active_user_count: -1,
|
||||
description: "🚀 Some new template that has no activity data",
|
||||
icon: "/icon/goland.svg",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
workspace_owner_count: 150,
|
||||
active_user_count: 150,
|
||||
description: "😮 Wow, this one has a bunch of usage!",
|
||||
icon: "",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import useTheme from "@material-ui/styles/useTheme"
|
||||
import { FC } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { createDayString } from "util/createDayString"
|
||||
import { formatTemplateActiveDevelopers } from "util/templates"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
||||
import { CodeExample } from "../../components/CodeExample/CodeExample"
|
||||
@@ -33,8 +34,8 @@ import {
|
||||
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
|
||||
|
||||
export const Language = {
|
||||
developerCount: (ownerCount: number): string => {
|
||||
return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}`
|
||||
developerCount: (activeCount: number): string => {
|
||||
return `${formatTemplateActiveDevelopers(activeCount)} developer${activeCount !== 1 ? "s" : ""}`
|
||||
},
|
||||
nameLabel: "Name",
|
||||
usedByLabel: "Used by",
|
||||
@@ -176,7 +177,7 @@ export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProp
|
||||
|
||||
<TableCellLink to={templatePageLink}>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{Language.developerCount(template.workspace_owner_count)}
|
||||
{Language.developerCount(template.active_user_count)}
|
||||
</span>
|
||||
</TableCellLink>
|
||||
|
||||
|
||||
@@ -175,7 +175,8 @@ export const MockTemplate: TypesGen.Template = {
|
||||
name: "test-template",
|
||||
provisioner: MockProvisioner.provisioners[0],
|
||||
active_version_id: MockTemplateVersion.id,
|
||||
workspace_owner_count: 1,
|
||||
workspace_owner_count: 2,
|
||||
active_user_count: 1,
|
||||
description: "This is a test description.",
|
||||
max_ttl_ms: 24 * 60 * 60 * 1000,
|
||||
min_autostart_interval_ms: 60 * 60 * 1000,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const formatTemplateActiveDevelopers = (num?: number): string => {
|
||||
if (num === undefined || num < 0) {
|
||||
// Loading
|
||||
return "-"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
Reference in New Issue
Block a user