Skip to content

Commit

Permalink
feat(cli): add --metrics-since flag to numerous status (#66)
Browse files Browse the repository at this point in the history
Allows querying the app workloads for CPU and memory metrics within a specified timeframe (since a duration (e.g. "1h") or a specific timestamp (e.g. "2024-06-06T12:00:00Z")
  • Loading branch information
jfeodor authored Dec 17, 2024
1 parent 8fd051c commit 337d714
Show file tree
Hide file tree
Showing 10 changed files with 291 additions and 95 deletions.
13 changes: 8 additions & 5 deletions cmd/status/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const longFormat = `Get an overview of the status of all workloads related to an
var long = fmt.Sprintf(longFormat, usage.AppIdentifier(cmdActionText), usage.AppDirectoryArgument)

var cmdArgs struct {
appIdent args.AppIdentifierArg
appDir string
appIdent args.AppIdentifierArg
appDir string
metricsSince Since
}

var Cmd = &cobra.Command{
Expand All @@ -40,9 +41,10 @@ func run(cmd *cobra.Command, args []string) error {
service := app.New(gql.NewClient(), nil, http.DefaultClient)

input := statusInput{
appDir: cmdArgs.appDir,
appSlug: cmdArgs.appIdent.AppSlug,
orgSlug: cmdArgs.appIdent.OrganizationSlug,
appDir: cmdArgs.appDir,
appSlug: cmdArgs.appIdent.AppSlug,
orgSlug: cmdArgs.appIdent.OrganizationSlug,
metricsSince: cmdArgs.metricsSince.Time(),
}
err := status(cmd.Context(), service, input)

Expand All @@ -52,4 +54,5 @@ func run(cmd *cobra.Command, args []string) error {
func init() {
flags := Cmd.Flags()
cmdArgs.appIdent.AddAppIdentifierFlags(flags, cmdActionText)
flags.Var(&cmdArgs.metricsSince, "metrics-since", "Read metrics since this time. Can be an RFC3339 timestamp (e.g. 2024-01-01T12:00:00Z), a plain date (e.g. 2024-06-06), or a duration of seconds, minutes, hours or days (e.g. 1s, 10m, 5h, d).")
}
46 changes: 46 additions & 0 deletions cmd/status/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package status

import (
"fmt"
"math"
"time"
)

const (
hoursPerDay int = 24
minutesPerHour int = 60
secondsPerMinute int = 60
)

func humanizeDuration(since time.Duration) string {
hours := int(math.Floor(since.Hours()))
if hours > hoursPerDay {
fullDays := hours / hoursPerDay
remainingHours := hours % hoursPerDay
if remainingHours > 0 {
return fmt.Sprintf("%d days and %d hours", fullDays, remainingHours)
} else {
return fmt.Sprintf("%d days", fullDays)
}
}

minutes := int(math.Floor(since.Minutes()))
if hours > 1 {
fullHours := hours
remainingMinutes := minutes % minutesPerHour
if fullHours > 0 {
return fmt.Sprintf("%d hours and %d minutes", fullHours, remainingMinutes)
}
}

seconds := int(math.Round(since.Seconds()))
if minutes > 1 {
fullMinutes := minutes
remainingSeconds := seconds % secondsPerMinute
if fullMinutes > 0.0 {
return fmt.Sprintf("%d minutes and %d seconds", fullMinutes, remainingSeconds)
}
}

return fmt.Sprintf("%d seconds", seconds)
}
47 changes: 47 additions & 0 deletions cmd/status/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package status

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestHumanizeDuration(t *testing.T) {
type testCase struct {
name string
duration time.Duration
expected string
}

for _, tc := range []testCase{
{
name: "seconds only",
duration: 5 * time.Second,
expected: "5 seconds",
},
{
name: "seconds are rounded",
duration: 7*time.Second + 10*time.Millisecond + 20*time.Microsecond,
expected: "7 seconds",
},
{
name: "minutes and seconds",
duration: 123 * time.Second,
expected: "2 minutes and 3 seconds",
},
{
name: "hours and minutes",
duration: 123 * time.Minute,
expected: "2 hours and 3 minutes",
},
{
name: "days and hours",
duration: 50 * time.Hour,
expected: "2 days and 2 hours",
},
} {
actual := humanizeDuration(tc.duration)
assert.Equal(t, tc.expected, actual)
}
}
92 changes: 92 additions & 0 deletions cmd/status/since.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package status

import (
"errors"
"regexp"
"strconv"
"time"
)

type Since time.Time

func (s *Since) String() string {
t := time.Time(*s)
return t.Format(time.RFC3339)
}

func (s *Since) Set(v string) error {
since, err := parseSince(v, time.Now())
if err != nil {
return err
}

*s = *since

return nil
}

func (*Since) Type() string {
return "Since time"
}

func (s *Since) Time() *time.Time {
if s == nil {
return nil
}

if time.Time(*s).IsZero() {
return nil
}

t := time.Time(*s)

return &t
}

var errParseSince = errors.New("could not parse since value")

func parseSince(value string, now time.Time) (*Since, error) {
// Try supported timestamp formats
for _, format := range []string{time.RFC3339, time.DateOnly} {
if parsed, err := time.Parse(format, value); err == nil {
since := Since(parsed)
return &since, nil
}
}

// Try relative time specifications
relTimePat := regexp.MustCompile(`^([1-9][0-9]*)(d|h|m|s)$`)
matches := relTimePat.FindStringSubmatch(value)
if len(matches) != 3 { // nolint:mnd
return nil, errParseSince
}

matchedValue, err := strconv.Atoi(matches[1])
if err != nil {
return nil, errParseSince
}

unitDuration, err := sinceUnitDuration(matches[2])
if err != nil {
return nil, errParseSince
}

result := Since(now.Add(-time.Duration(matchedValue) * unitDuration))

return &result, nil
}

func sinceUnitDuration(unit string) (time.Duration, error) {
switch unit {
case "d":
return time.Hour * 24, nil // nolint:mnd
case "h":
return time.Hour, nil
case "m":
return time.Minute, nil
case "s":
return time.Second, nil
default:
return 0, errParseSince
}
}
61 changes: 61 additions & 0 deletions cmd/status/since_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package status

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestSince(t *testing.T) {
t.Run("Set", func(t *testing.T) {
t.Run("sets value", func(t *testing.T) {
var actual Since

err := actual.Set("2024-01-01T10:11:12Z")

assert.NoError(t, err)
expected := time.Date(2024, time.January, 1, 10, 11, 12, 0, time.UTC)
assert.Equal(t, Since(expected), actual)
})

t.Run("returns error", func(t *testing.T) {
var actual Since

err := actual.Set("invalid-since-value")

assert.ErrorIs(t, err, errParseSince)
})
})
}

func TestParseSince(t *testing.T) {
now := time.Date(2024, time.January, 1, 12, 0, 0, 0, time.UTC)
type testCase struct {
value string
expected time.Time
}

for _, tc := range []testCase{
{value: "1h", expected: now.Add(-time.Hour)},
{value: "123h", expected: now.Add(-123 * time.Hour)},
{value: "5d", expected: now.Add(-5 * 24 * time.Hour)},
{value: "1000d", expected: now.Add(-1000 * 24 * time.Hour)},
{value: "3m", expected: now.Add(-3 * time.Minute)},
{value: "120m", expected: now.Add(-120 * time.Minute)},
{value: "2s", expected: now.Add(-2 * time.Second)},
{value: "8600s", expected: now.Add(-8600 * time.Second)},
{value: "2024-01-01T10:11:12Z", expected: time.Date(2024, time.January, 1, 10, 11, 12, 0, time.UTC)},
{value: "2024-01-01T10:11:12+02:00", expected: time.Date(2024, time.January, 1, 10, 11, 12, 0, time.FixedZone("", int((2*time.Hour).Seconds())))},
{value: "2024-01-01", expected: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)},
} {
t.Run(tc.value, func(t *testing.T) {
actual, err := parseSince(tc.value, now)

assert.NoError(t, err)
if assert.NotNil(t, actual) {
assert.Equal(t, Since(tc.expected), *actual)
}
})
}
}
51 changes: 6 additions & 45 deletions cmd/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package status
import (
"context"
"fmt"
"math"
"time"

"numerous.com/cli/cmd/output"
Expand All @@ -13,9 +12,10 @@ import (
)

type statusInput struct {
appDir string
appSlug string
orgSlug string
appDir string
appSlug string
orgSlug string
metricsSince *time.Time
}

type appReaderWorkloadLister interface {
Expand All @@ -41,7 +41,7 @@ func status(ctx context.Context, apps appReaderWorkloadLister, input statusInput
println("Description: " + readOutput.AppDescription)
}

workloads, err := apps.ListAppWorkloads(ctx, app.ListAppWorkloadsInput{AppID: readOutput.AppID})
workloads, err := apps.ListAppWorkloads(ctx, app.ListAppWorkloadsInput{AppID: readOutput.AppID, MetricsSince: input.metricsSince})
if err != nil {
app.PrintAppError(err, ai)
return err
Expand Down Expand Up @@ -87,7 +87,7 @@ func printWorkload(workload app.AppWorkload) {
func printLogs(entries []app.AppDeployLogEntry) {
fmt.Println(" Logs (last 10 lines):")
for _, entry := range entries {
fmt.Println(" " + output.AnsiFaint + entry.Timestamp.Format(time.RFC3339) + output.AnsiReset + entry.Text)
fmt.Println(" "+output.AnsiFaint+entry.Timestamp.Format(time.RFC3339)+output.AnsiReset, entry.Text)
}
}

Expand All @@ -111,45 +111,6 @@ func printPlot(prefix string, t timeseries.Timeseries) {
p.Display(prefix, plotHeight)
}

const (
hoursPerDay int = 24
minutesPerHour int = 60
secondsPerMinute int = 60
)

func humanizeDuration(since time.Duration) string {
hours := int(math.Floor(since.Hours()))
if hours > hoursPerDay {
fullDays := hours / hoursPerDay
remainingHours := hours % hoursPerDay
if remainingHours > 0 {
return fmt.Sprintf("%d days and %d hours", fullDays, remainingHours)
} else {
return fmt.Sprintf("%d days", fullDays)
}
}

minutes := int(math.Floor(since.Minutes()))
if hours > 1 {
fullHours := hours
remainingMinutes := minutes % minutesPerHour
if fullHours > 0 {
return fmt.Sprintf("%d hours and %d minutes", fullHours, remainingMinutes)
}
}

seconds := int(math.Round(since.Seconds()))
if minutes > 1 {
fullMinutes := minutes
remainingSeconds := seconds % secondsPerMinute
if fullMinutes > 0.0 {
return fmt.Sprintf("%d minutes and %d seconds", fullMinutes, remainingSeconds)
}
}

return fmt.Sprintf("%d seconds", seconds)
}

func formatUsage(usage app.AppWorkloadResourceUsage) string {
if usage.Limit == nil {
return fmt.Sprintf("%2.f", usage.Current)
Expand Down
Loading

0 comments on commit 337d714

Please sign in to comment.