Skip to content

Commit dd3fa2a

Browse files
committed
feat(cli): allow showing schedules for multiple workspaces
1 parent 177affb commit dd3fa2a

13 files changed

+516
-431
lines changed

cli/constants.go

-6
This file was deleted.

cli/list.go

+36-48
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
package cli
22

33
import (
4+
"context"
45
"fmt"
56
"strconv"
67
"time"
78

8-
"github.com/google/uuid"
9-
10-
"github.com/coder/pretty"
9+
"golang.org/x/xerrors"
1110

1211
"github.com/coder/coder/v2/cli/clibase"
1312
"github.com/coder/coder/v2/cli/cliui"
14-
"github.com/coder/coder/v2/coderd/schedule/cron"
15-
"github.com/coder/coder/v2/coderd/util/ptr"
1613
"github.com/coder/coder/v2/codersdk"
14+
"github.com/coder/pretty"
1715
)
1816

1917
// workspaceListRow is the type provided to the OutputFormatter. This is a bit
@@ -31,55 +29,42 @@ type workspaceListRow struct {
3129
LastBuilt string `json:"-" table:"last built"`
3230
Outdated bool `json:"-" table:"outdated"`
3331
StartsAt string `json:"-" table:"starts at"`
32+
StartsNext string `json:"-" table:"starts next"`
3433
StopsAfter string `json:"-" table:"stops after"`
34+
StopsNext string `json:"-" table:"stops next"`
3535
DailyCost string `json:"-" table:"daily cost"`
3636
}
3737

38-
func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow {
38+
func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow {
3939
status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
4040

4141
lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
42-
autostartDisplay := "-"
43-
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
44-
if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil {
45-
autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
46-
}
47-
}
48-
49-
autostopDisplay := "-"
50-
if !ptr.NilOrZero(workspace.TTLMillis) {
51-
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
52-
autostopDisplay = durationDisplay(dur)
53-
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.Time.After(now) && status == "Running" {
54-
remaining := time.Until(workspace.LatestBuild.Deadline.Time)
55-
autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining))
56-
}
57-
}
42+
schedRow := scheduleListRowFromWorkspace(now, workspace)
5843

5944
healthy := ""
6045
if status == "Starting" || status == "Started" {
6146
healthy = strconv.FormatBool(workspace.Health.Healthy)
6247
}
63-
user := usersByID[workspace.OwnerID]
6448
return workspaceListRow{
6549
Workspace: workspace,
66-
WorkspaceName: user.Username + "/" + workspace.Name,
50+
WorkspaceName: workspace.OwnerName + "/" + workspace.Name,
6751
Template: workspace.TemplateName,
6852
Status: status,
6953
Healthy: healthy,
7054
LastBuilt: durationDisplay(lastBuilt),
7155
Outdated: workspace.Outdated,
72-
StartsAt: autostartDisplay,
73-
StopsAfter: autostopDisplay,
56+
StartsAt: schedRow.StartsAt,
57+
StartsNext: schedRow.StartsNext,
58+
StopsAfter: schedRow.StopsAfter,
59+
StopsNext: schedRow.StopsNext,
7460
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
7561
}
7662
}
7763

7864
func (r *RootCmd) list() *clibase.Cmd {
7965
var (
80-
filter cliui.WorkspaceFilter
81-
displayWorkspaces []workspaceListRow
82-
formatter = cliui.NewOutputFormatter(
66+
filter cliui.WorkspaceFilter
67+
formatter = cliui.NewOutputFormatter(
8368
cliui.TableFormat(
8469
[]workspaceListRow{},
8570
[]string{
@@ -107,35 +92,20 @@ func (r *RootCmd) list() *clibase.Cmd {
10792
r.InitClient(client),
10893
),
10994
Handler: func(inv *clibase.Invocation) error {
110-
res, err := client.Workspaces(inv.Context(), filter.Filter())
95+
res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace)
11196
if err != nil {
11297
return err
11398
}
114-
if len(res.Workspaces) == 0 {
99+
100+
if len(res) == 0 {
115101
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
116102
_, _ = fmt.Fprintln(inv.Stderr)
117103
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>"))
118104
_, _ = fmt.Fprintln(inv.Stderr)
119105
return nil
120106
}
121107

122-
userRes, err := client.Users(inv.Context(), codersdk.UsersRequest{})
123-
if err != nil {
124-
return err
125-
}
126-
127-
usersByID := map[uuid.UUID]codersdk.User{}
128-
for _, user := range userRes.Users {
129-
usersByID[user.ID] = user
130-
}
131-
132-
now := time.Now()
133-
displayWorkspaces = make([]workspaceListRow, len(res.Workspaces))
134-
for i, workspace := range res.Workspaces {
135-
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
136-
}
137-
138-
out, err := formatter.Format(inv.Context(), displayWorkspaces)
108+
out, err := formatter.Format(inv.Context(), res)
139109
if err != nil {
140110
return err
141111
}
@@ -148,3 +118,21 @@ func (r *RootCmd) list() *clibase.Cmd {
148118
formatter.AttachOptions(&cmd.Options)
149119
return cmd
150120
}
121+
122+
// queryConvertWorkspaces is a helper function for converting
123+
// codersdk.Workspaces to a different type.
124+
// It's used by the list command to convert workspaces to
125+
// workspaceListRow, and by the schedule command to
126+
// convert workspaces to scheduleListRow.
127+
func queryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) {
128+
var empty []T
129+
workspaces, err := client.Workspaces(ctx, filter)
130+
if err != nil {
131+
return empty, xerrors.Errorf("query workspaces: %w", err)
132+
}
133+
converted := make([]T, len(workspaces.Workspaces))
134+
for i, workspace := range workspaces.Workspaces {
135+
converted[i] = convertF(time.Now(), workspace)
136+
}
137+
return converted, nil
138+
}

cli/schedule.go

+87-43
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package cli
33
import (
44
"fmt"
55
"io"
6+
"strings"
67
"time"
78

8-
"github.com/jedib0t/go-pretty/v6/table"
99
"golang.org/x/xerrors"
1010

1111
"github.com/coder/coder/v2/cli/clibase"
@@ -17,7 +17,7 @@ import (
1717
)
1818

1919
const (
20-
scheduleShowDescriptionLong = `Shows the following information for the given workspace:
20+
scheduleShowDescriptionLong = `Shows the following information for the given workspace(s):
2121
* The automatic start schedule
2222
* The next scheduled start time
2323
* The duration after which it will stop
@@ -72,25 +72,67 @@ func (r *RootCmd) schedules() *clibase.Cmd {
7272
return scheduleCmd
7373
}
7474

75+
// scheduleShow() is just a wrapper for list() with some different defaults.
7576
func (r *RootCmd) scheduleShow() *clibase.Cmd {
77+
var (
78+
filter cliui.WorkspaceFilter
79+
formatter = cliui.NewOutputFormatter(
80+
cliui.TableFormat(
81+
[]scheduleListRow{},
82+
[]string{
83+
"workspace",
84+
"starts at",
85+
"starts next",
86+
"stops after",
87+
"stops next",
88+
},
89+
),
90+
cliui.JSONFormat(),
91+
)
92+
)
7693
client := new(codersdk.Client)
7794
showCmd := &clibase.Cmd{
78-
Use: "show <workspace-name>",
79-
Short: "Show workspace schedule",
95+
Use: "show [<workspace>] | [--search <query>] [--all]",
96+
Short: "Show workspace schedules",
8097
Long: scheduleShowDescriptionLong,
8198
Middleware: clibase.Chain(
82-
clibase.RequireNArgs(1),
99+
clibase.RequireRangeArgs(0, 1),
83100
r.InitClient(client),
84101
),
85102
Handler: func(inv *clibase.Invocation) error {
86-
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
103+
// To preserve existing behavior, if an argument is passed we will
104+
// only show the schedule for that workspace.
105+
// This will clobber the search query if one is passed.
106+
f := filter.Filter()
107+
if len(inv.Args) == 1 {
108+
// If the argument contains a slash, we assume it's a full owner/name reference
109+
if strings.Contains(inv.Args[0], "/") {
110+
_, workspaceName, err := splitNamedWorkspace(inv.Args[0])
111+
if err != nil {
112+
return err
113+
}
114+
f.FilterQuery = fmt.Sprintf("name:%s", workspaceName)
115+
} else {
116+
// Otherwise, we assume it's a workspace name owned by the current user
117+
f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0])
118+
}
119+
}
120+
res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace)
87121
if err != nil {
88122
return err
89123
}
90124

91-
return displaySchedule(workspace, inv.Stdout)
125+
out, err := formatter.Format(inv.Context(), res)
126+
if err != nil {
127+
return err
128+
}
129+
130+
_, err = fmt.Fprintln(inv.Stdout, out)
131+
return err
92132
},
93133
}
134+
filter.AttachOptions(&showCmd.Options)
135+
formatter.AttachOptions(&showCmd.Options)
94136
return showCmd
95137
}
96138

@@ -242,50 +284,52 @@ func (r *RootCmd) scheduleOverride() *clibase.Cmd {
242284
return overrideCmd
243285
}
244286

245-
func displaySchedule(workspace codersdk.Workspace, out io.Writer) error {
246-
loc, err := tz.TimezoneIANA()
287+
func displaySchedule(ws codersdk.Workspace, out io.Writer) error {
288+
rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)}
289+
rendered, err := cliui.DisplayTable(rows, "workspace", []string{
290+
"workspace", "starts at", "starts next", "stops after", "stops next",
291+
})
247292
if err != nil {
248-
loc = time.UTC // best effort
293+
return err
249294
}
295+
_, _ = fmt.Fprintln(out, rendered)
296+
return nil
297+
}
250298

251-
var (
252-
schedStart = "manual"
253-
schedStop = "manual"
254-
schedNextStart = "-"
255-
schedNextStop = "-"
256-
)
299+
// scheduleListRow is a row in the schedule list.
300+
// this is required for proper JSON output.
301+
type scheduleListRow struct {
302+
WorkspaceName string `json:"workspace" table:"workspace,default_sort"`
303+
StartsAt string `json:"starts_at" table:"starts at"`
304+
StartsNext string `json:"starts_next" table:"starts next"`
305+
StopsAfter string `json:"stops_after" table:"stops after"`
306+
StopsNext string `json:"stops_next" table:"stops next"`
307+
}
308+
309+
func scheduleListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) scheduleListRow {
310+
autostartDisplay := ""
311+
nextStartDisplay := ""
257312
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
258-
sched, err := cron.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule))
259-
if err != nil {
260-
// This should never happen.
261-
_, _ = fmt.Fprintf(out, "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error())
262-
return nil
313+
if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil {
314+
autostartDisplay = sched.Humanize()
315+
nextStartDisplay = timeDisplay(sched.Next(now))
263316
}
264-
schedNext := sched.Next(time.Now()).In(sched.Location())
265-
schedStart = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
266-
schedNextStart = schedNext.Format(timeFormat + " on " + dateFormat)
267317
}
268318

319+
autostopDisplay := ""
320+
nextStopDisplay := ""
269321
if !ptr.NilOrZero(workspace.TTLMillis) {
270-
d := time.Duration(*workspace.TTLMillis) * time.Millisecond
271-
schedStop = durationDisplay(d) + " after start"
272-
}
273-
274-
if !workspace.LatestBuild.Deadline.IsZero() {
275-
if workspace.LatestBuild.Transition != "start" {
276-
schedNextStop = "-"
277-
} else {
278-
schedNextStop = workspace.LatestBuild.Deadline.Time.In(loc).Format(timeFormat + " on " + dateFormat)
279-
schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline.Time)))
322+
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
323+
autostopDisplay = durationDisplay(dur)
324+
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
325+
nextStopDisplay = timeDisplay(workspace.LatestBuild.Deadline.Time)
280326
}
281327
}
282-
283-
tw := cliui.Table()
284-
tw.AppendRow(table.Row{"Starts at", schedStart})
285-
tw.AppendRow(table.Row{"Starts next", schedNextStart})
286-
tw.AppendRow(table.Row{"Stops at", schedStop})
287-
tw.AppendRow(table.Row{"Stops next", schedNextStop})
288-
289-
_, _ = fmt.Fprintln(out, tw.Render())
290-
return nil
328+
return scheduleListRow{
329+
WorkspaceName: workspace.OwnerName + "/" + workspace.Name,
330+
StartsAt: autostartDisplay,
331+
StartsNext: nextStartDisplay,
332+
StopsAfter: autostopDisplay,
333+
StopsNext: nextStopDisplay,
334+
}
291335
}

0 commit comments

Comments
 (0)