Skip to content

Commit 2d662e4

Browse files
committed
feat(cli): allow showing schedules for multiple workspaces
1 parent 8a7f0e9 commit 2d662e4

File tree

4 files changed

+123
-97
lines changed

4 files changed

+123
-97
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.Workspcaes 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

+76-43
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"io"
66
"time"
77

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

1110
"github.com/coder/coder/v2/cli/clibase"
@@ -17,7 +16,7 @@ import (
1716
)
1817

1918
const (
20-
scheduleShowDescriptionLong = `Shows the following information for the given workspace:
19+
scheduleShowDescriptionLong = `Shows the following information for the given workspace(s):
2120
* The automatic start schedule
2221
* The next scheduled start time
2322
* The duration after which it will stop
@@ -72,25 +71,57 @@ func (r *RootCmd) schedules() *clibase.Cmd {
7271
return scheduleCmd
7372
}
7473

74+
// scheduleShow() is just a wrapper for list() with some different defaults.
7575
func (r *RootCmd) scheduleShow() *clibase.Cmd {
76+
var (
77+
filter cliui.WorkspaceFilter
78+
formatter = cliui.NewOutputFormatter(
79+
cliui.TableFormat(
80+
[]scheduleListRow{},
81+
[]string{
82+
"workspace",
83+
"starts at",
84+
"starts next",
85+
"stops after",
86+
"stops next",
87+
},
88+
),
89+
cliui.JSONFormat(),
90+
)
91+
)
7692
client := new(codersdk.Client)
7793
showCmd := &clibase.Cmd{
78-
Use: "show <workspace-name>",
79-
Short: "Show workspace schedule",
94+
Use: "show <workspace 1> ... <workspace N>",
95+
Short: "Show workspace schedules",
8096
Long: scheduleShowDescriptionLong,
8197
Middleware: clibase.Chain(
82-
clibase.RequireNArgs(1),
98+
clibase.RequireRangeArgs(0, 1),
8399
r.InitClient(client),
84100
),
85101
Handler: func(inv *clibase.Invocation) error {
86-
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
102+
// To preserve existing behavior, if an argument is passed we will
103+
// only show the schedule for that workspace.
104+
// This will clobber the search query if one is passed.
105+
f := filter.Filter()
106+
if len(inv.Args) == 1 {
107+
f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0])
108+
}
109+
res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace)
110+
if err != nil {
111+
return err
112+
}
113+
114+
out, err := formatter.Format(inv.Context(), res)
87115
if err != nil {
88116
return err
89117
}
90118

91-
return displaySchedule(workspace, inv.Stdout)
119+
_, err = fmt.Fprintln(inv.Stdout, out)
120+
return err
92121
},
93122
}
123+
filter.AttachOptions(&showCmd.Options)
124+
formatter.AttachOptions(&showCmd.Options)
94125
return showCmd
95126
}
96127

@@ -242,50 +273,52 @@ func (r *RootCmd) scheduleOverride() *clibase.Cmd {
242273
return overrideCmd
243274
}
244275

245-
func displaySchedule(workspace codersdk.Workspace, out io.Writer) error {
246-
loc, err := tz.TimezoneIANA()
276+
func displaySchedule(ws codersdk.Workspace, out io.Writer) error {
277+
rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)}
278+
rendered, err := cliui.DisplayTable(rows, "workspace", []string{
279+
"workspace", "starts at", "starts next", "stops after", "stops next",
280+
})
247281
if err != nil {
248-
loc = time.UTC // best effort
282+
return err
249283
}
284+
_, _ = fmt.Fprintln(out, rendered)
285+
return nil
286+
}
250287

251-
var (
252-
schedStart = "manual"
253-
schedStop = "manual"
254-
schedNextStart = "-"
255-
schedNextStop = "-"
256-
)
288+
// scheduleListRow is a row in the schedule list.
289+
// this is required for proper JSON output.
290+
type scheduleListRow struct {
291+
WorkspaceName string `json:"workspace" table:"workspace,default_sort"`
292+
StartsAt string `json:"starts_at" table:"starts at"`
293+
StartsNext string `json:"starts_next" table:"starts next"`
294+
StopsAfter string `json:"stops_after" table:"stops after"`
295+
StopsNext string `json:"stops_next" table:"stops next"`
296+
}
297+
298+
func scheduleListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) scheduleListRow {
299+
autostartDisplay := ""
300+
nextStartDisplay := ""
257301
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
302+
if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil {
303+
autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
304+
nextStartDisplay = timeDisplay(sched.Next(now))
263305
}
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)
267306
}
268307

308+
autostopDisplay := ""
309+
nextStopDisplay := ""
269310
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)))
311+
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
312+
autostopDisplay = durationDisplay(dur)
313+
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
314+
nextStopDisplay = timeDisplay(workspace.LatestBuild.Deadline.Time)
280315
}
281316
}
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
317+
return scheduleListRow{
318+
WorkspaceName: workspace.Name,
319+
StartsAt: autostartDisplay,
320+
StartsNext: nextStartDisplay,
321+
StopsAfter: autostopDisplay,
322+
StopsNext: nextStopDisplay,
323+
}
291324
}

cli/util.go

+11
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ func durationDisplay(d time.Duration) string {
6262
return sign + durationDisplay
6363
}
6464

65+
// timeDisplay formats a time in the local timezone
66+
// in RFC3339 format.
67+
func timeDisplay(t time.Time) string {
68+
localTz, err := tz.TimezoneIANA()
69+
if err != nil {
70+
localTz = time.UTC
71+
}
72+
73+
return t.In(localTz).Format(time.RFC3339)
74+
}
75+
6576
// relative relativizes a duration with the prefix "ago" or "in"
6677
func relative(d time.Duration) string {
6778
if d > 0 {

0 commit comments

Comments
 (0)