diff --git a/internal/api/appid.go b/internal/api/appid.go new file mode 100644 index 0000000..5fa17df --- /dev/null +++ b/internal/api/appid.go @@ -0,0 +1,16 @@ +package api + +import ( + "fmt" + "strings" +) + +// NormalizeAppID ensures an app ID carries its project ID prefix, so commands can +// accept either the bare app name or the fully qualified ID. +func NormalizeAppID(projectID, appName string) string { + expectedPrefix := projectID + "-" + if strings.HasPrefix(appName, expectedPrefix) { + return appName + } + return fmt.Sprintf("%s-%s", projectID, appName) +} diff --git a/internal/api/appid_test.go b/internal/api/appid_test.go new file mode 100644 index 0000000..b1ed13a --- /dev/null +++ b/internal/api/appid_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeAppID(t *testing.T) { + tcs := []struct { + name string + projectID string + appName string + expected string + }{ + { + name: "app name without project prefix", + projectID: "dev-p-0780791d", + appName: "5-dockerfile", + expected: "dev-p-0780791d-5-dockerfile", + }, + { + name: "app name with project prefix", + projectID: "dev-p-0780791d", + appName: "dev-p-0780791d-5-dockerfile", + expected: "dev-p-0780791d-5-dockerfile", + }, + { + name: "app name with partial match", + projectID: "dev-p-0780791d", + appName: "dev-p-123-myapp", + expected: "dev-p-0780791d-dev-p-123-myapp", + }, + { + name: "simple app name", + projectID: "project-123", + appName: "myapp", + expected: "project-123-myapp", + }, + { + name: "app name already has full ID", + projectID: "project-123", + appName: "project-123-myapp-v2", + expected: "project-123-myapp-v2", + }, + { + name: "edge case - empty app name", + projectID: "project-123", + appName: "", + expected: "project-123-", + }, + { + name: "edge case - app name with only dash", + projectID: "project-123", + appName: "-test", + expected: "project-123--test", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + result := NormalizeAppID(tc.projectID, tc.appName) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 9f2dc46..e85f3b2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1067,6 +1067,34 @@ func (c *client) GetRuns(ctx context.Context, projectID, appID string, asyncOnly return response.Items, nil } +// GetResourceMetrics retrieves CPU, memory and GPU memory utilisation for an app +// over a time range. +func (c *client) GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error) { + params := url.Values{} + params.Set("start", opts.Start.UTC().Format(time.RFC3339)) + params.Set("end", opts.End.UTC().Format(time.RFC3339)) + if opts.ContainerID != "" { + params.Set("container_id", opts.ContainerID) + } + if opts.Resolution != "" { + params.Set("resolution", opts.Resolution) + } + + path := fmt.Sprintf("v2/projects/%s/apps/%s/resource-metrics?%s", projectID, appID, params.Encode()) + + body, err := c.request(ctx, "GET", path, nil, true) + if err != nil { + return nil, err + } + + var metrics ResourceMetrics + if err := json.Unmarshal(body, &metrics); err != nil { + return nil, fmt.Errorf("failed to parse resource metrics response: %w", err) + } + + return &metrics, nil +} + // ListSecrets retrieves the secrets for a project func (c *client) ListSecrets(ctx context.Context, projectID string) (map[string]string, error) { path := fmt.Sprintf("v2/projects/%s/secrets", projectID) diff --git a/internal/api/interface.go b/internal/api/interface.go index 7416db7..0c66310 100644 --- a/internal/api/interface.go +++ b/internal/api/interface.go @@ -10,6 +10,7 @@ type Client interface { GetProjects(ctx context.Context) ([]Project, error) GetRuns(ctx context.Context, projectID, appID string, asyncOnly bool) ([]Run, error) ListContainers(ctx context.Context, projectID, appID string) ([]Container, error) + GetResourceMetrics(ctx context.Context, projectID, appID string, opts ResourceMetricsOptions) (*ResourceMetrics, error) FetchAppLogs(ctx context.Context, projectID, appID string, opts AppLogOptions) (*AppLogsResponse, error) // Deploy methods diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go new file mode 100644 index 0000000..494a1a1 --- /dev/null +++ b/internal/api/metrics_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetricValueUnmarshal(t *testing.T) { + tcs := []struct { + name string + input string + wantValid bool + wantValue float64 + wantErr bool + }{ + {name: "quoted value, as the API sends it", input: `"1.0432586960842736"`, wantValid: true, wantValue: 1.0432586960842736}, + {name: "padded gap", input: `null`, wantValid: false}, + {name: "empty string", input: `""`, wantValid: false}, + {name: "bare number", input: `2.5`, wantValid: true, wantValue: 2.5}, + {name: "quoted zero is a real measurement", input: `"0"`, wantValid: true, wantValue: 0}, + {name: "quoted NaN counts as no sample", input: `"NaN"`, wantValid: false}, + {name: "quoted infinity counts as no sample", input: `"+Inf"`, wantValid: false}, + {name: "unparseable string", input: `"not-a-number"`, wantErr: true}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + var value MetricValue + err := json.Unmarshal([]byte(tc.input), &value) + + if tc.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantValid, value.Valid) + if tc.wantValid { + assert.Equal(t, tc.wantValue, value.Value) + } + }) + } +} + +// Values go out as numbers regardless of how they came in, so consumers of our +// JSON can compare them without unquoting first. +func TestMetricValueMarshalsAsNumber(t *testing.T) { + var series ChartSeries + require.NoError(t, json.Unmarshal([]byte(`{"name":"Max","data":["1.5",null,"2"]}`), &series)) + + out, err := json.Marshal(series) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Max","data":[1.5,null,2]}`, string(out)) +} + +func TestResourceMetricsUnmarshal(t *testing.T) { + body := `{ + "cpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": ["0.5", "1.5"]}]}, + "memory": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, "8"]}]}, + "gpu": {"timestamps": [1, 2], "series": [{"name": "Max", "data": [null, null]}]}, + "containers": {"timestamps": [1], "series": []}, + "requests": {"timestamps": [1], "series": []} + }` + + var metrics ResourceMetrics + require.NoError(t, json.Unmarshal([]byte(body), &metrics)) + + assert.Equal(t, []int64{1, 2}, metrics.CPU.Timestamps) + require.Len(t, metrics.CPU.Series, 1) + assert.Equal(t, "Max", metrics.CPU.Series[0].Name) + assert.True(t, metrics.CPU.Series[0].Data[1].Valid) + assert.Equal(t, 1.5, metrics.CPU.Series[0].Data[1].Value) + + assert.False(t, metrics.Memory.Series[0].Data[0].Valid) + assert.True(t, metrics.Memory.Series[0].Data[1].Valid) + + // A GPU series that never reported stays absent rather than reading as zero. + assert.False(t, metrics.GPU.Series[0].Data[0].Valid) + assert.False(t, metrics.GPU.Series[0].Data[1].Valid) +} diff --git a/internal/api/mock/client_gen.go b/internal/api/mock/client_gen.go index bf42122..3029a74 100644 --- a/internal/api/mock/client_gen.go +++ b/internal/api/mock/client_gen.go @@ -1271,6 +1271,86 @@ func (_c *MockClient_GetProjects_Call) RunAndReturn(run func(ctx context.Context return _c } +// GetResourceMetrics provides a mock function for the type MockClient +func (_mock *MockClient) GetResourceMetrics(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions) (*api.ResourceMetrics, error) { + ret := _mock.Called(ctx, projectID, appID, opts) + + if len(ret) == 0 { + panic("no return value specified for GetResourceMetrics") + } + + var r0 *api.ResourceMetrics + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, api.ResourceMetricsOptions) (*api.ResourceMetrics, error)); ok { + return returnFunc(ctx, projectID, appID, opts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, api.ResourceMetricsOptions) *api.ResourceMetrics); ok { + r0 = returnFunc(ctx, projectID, appID, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.ResourceMetrics) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, api.ResourceMetricsOptions) error); ok { + r1 = returnFunc(ctx, projectID, appID, opts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetResourceMetrics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetResourceMetrics' +type MockClient_GetResourceMetrics_Call struct { + *mock.Call +} + +// GetResourceMetrics is a helper method to define mock.On call +// - ctx context.Context +// - projectID string +// - appID string +// - opts api.ResourceMetricsOptions +func (_e *MockClient_Expecter) GetResourceMetrics(ctx interface{}, projectID interface{}, appID interface{}, opts interface{}) *MockClient_GetResourceMetrics_Call { + return &MockClient_GetResourceMetrics_Call{Call: _e.mock.On("GetResourceMetrics", ctx, projectID, appID, opts)} +} + +func (_c *MockClient_GetResourceMetrics_Call) Run(run func(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions)) *MockClient_GetResourceMetrics_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 api.ResourceMetricsOptions + if args[3] != nil { + arg3 = args[3].(api.ResourceMetricsOptions) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockClient_GetResourceMetrics_Call) Return(resourceMetrics *api.ResourceMetrics, err error) *MockClient_GetResourceMetrics_Call { + _c.Call.Return(resourceMetrics, err) + return _c +} + +func (_c *MockClient_GetResourceMetrics_Call) RunAndReturn(run func(ctx context.Context, projectID string, appID string, opts api.ResourceMetricsOptions) (*api.ResourceMetrics, error)) *MockClient_GetResourceMetrics_Call { + _c.Call.Return(run) + return _c +} + // GetRunStatus provides a mock function for the type MockClient func (_mock *MockClient) GetRunStatus(ctx context.Context, projectID string, appName string, runID string) (*api.RunStatus, error) { ret := _mock.Called(ctx, projectID, appName, runID) diff --git a/internal/api/types.go b/internal/api/types.go index d0ab6ce..55e3486 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -376,6 +378,94 @@ type Container struct { IsTerminating bool `json:"isTerminating"` } +// MetricValue is a single sample in a metric series. The API relays raw query +// values, which arrive as JSON strings, and pads gaps with null — so it decodes +// from either form. It marshals back out as a plain number (or null) so our own +// JSON output is directly comparable. +type MetricValue struct { + Value float64 + Valid bool +} + +func (m *MetricValue) UnmarshalJSON(b []byte) error { + s := string(b) + if s == "null" { + return nil + } + + if strings.HasPrefix(s, `"`) { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + if str == "" { + return nil + } + value, err := strconv.ParseFloat(str, 64) + if err != nil { + return fmt.Errorf("failed to parse metric value %q: %w", str, err) + } + m.set(value) + return nil + } + + var value float64 + if err := json.Unmarshal(b, &value); err != nil { + return err + } + m.set(value) + return nil +} + +// set records a sample, treating NaN and infinities as absent — they mean the +// query had nothing to report, and they are not representable in JSON. +func (m *MetricValue) set(value float64) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return + } + m.Value = value + m.Valid = true +} + +func (m MetricValue) MarshalJSON() ([]byte, error) { + if !m.Valid { + return []byte("null"), nil + } + return json.Marshal(m.Value) +} + +// ChartSeries is one named line of a resource metric. Data is parallel to the +// enclosing ChartData's Timestamps; entries are invalid where the range was +// padded because no sample existed. +type ChartSeries struct { + Name string `json:"name"` + Data []MetricValue `json:"data"` +} + +// ChartData is a time series for a single resource metric. At app level it carries +// a P50, P90 and Max series; scoped to one container it carries a single series. +type ChartData struct { + Timestamps []int64 `json:"timestamps"` + Series []ChartSeries `json:"series"` +} + +// ResourceMetrics holds utilisation time series for an app. The endpoint also +// returns container-count and request-concurrency charts, which the CLI does not +// surface yet. +type ResourceMetrics struct { + CPU ChartData `json:"cpu"` // cores + Memory ChartData `json:"memory"` // GB of RAM + GPU ChartData `json:"gpu"` // GB of GPU memory (VRAM) +} + +// ResourceMetricsOptions contains parameters for fetching resource metrics +type ResourceMetricsOptions struct { + Start time.Time + End time.Time + ContainerID string // Scope to a single container (optional) + Resolution string // "medium" or "high" for more data points (optional) +} + // AppBuild represents a build for a Cerebrium application type AppBuild struct { Id string `json:"id"` diff --git a/internal/commands/containers/list.go b/internal/commands/containers/list.go index c28181d..d494e4b 100644 --- a/internal/commands/containers/list.go +++ b/internal/commands/containers/list.go @@ -2,7 +2,6 @@ package containers import ( "fmt" - "strings" "github.com/cerebriumai/cerebrium/internal/api" "github.com/cerebriumai/cerebrium/internal/ui" @@ -50,7 +49,7 @@ func runList(cmd *cobra.Command, appName string) error { return ui.NewValidationError(fmt.Errorf("no project selected: %w", err)) } - appID := normalizeAppID(projectID, appName) + appID := api.NormalizeAppID(projectID, appName) client, err := api.NewClient(cfg) if err != nil { @@ -92,12 +91,3 @@ func runList(cmd *cobra.Command, appName string) error { return nil } - -// normalizeAppID ensures the app ID has the project ID prefix. -func normalizeAppID(projectID, appName string) string { - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - return fmt.Sprintf("%s-%s", projectID, appName) -} diff --git a/internal/commands/metrics/resources.go b/internal/commands/metrics/resources.go new file mode 100644 index 0000000..479cac5 --- /dev/null +++ b/internal/commands/metrics/resources.go @@ -0,0 +1,270 @@ +package metrics + +import ( + "fmt" + "time" + + "github.com/cerebriumai/cerebrium/internal/api" + "github.com/cerebriumai/cerebrium/internal/ui" + "github.com/cerebriumai/cerebrium/pkg/config" + "github.com/spf13/cobra" +) + +type resourceFlags struct { + since time.Duration + start string + end string + containerID string + resolution string +} + +// seriesPeak is the highest value a single series reached over the window. Peak is +// nil when the series carried no samples at all. +type seriesPeak struct { + Name string `json:"name"` + Peak *float64 `json:"peak"` +} + +type metricSummary struct { + Unit string `json:"unit"` + Series []seriesPeak `json:"series"` +} + +type metricsOutput struct { + AppID string `json:"appId"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + ContainerID string `json:"containerId,omitempty"` + Summary map[string]metricSummary `json:"summary"` + Metrics *api.ResourceMetrics `json:"metrics"` +} + +func newResourcesCmd() *cobra.Command { + var flags resourceFlags + + cmd := &cobra.Command{ + Use: "resources APP_NAME", + Short: "Show CPU, memory and GPU memory usage for an app", + Long: `Show how much CPU, memory and GPU memory (VRAM) an app actually used over a +time window, so you can right-size the hardware in your cerebrium.toml. + +Values are peaks over the window: CPU in cores, memory and GPU memory in GB. + +Examples: + cerebrium metrics resources my-app + cerebrium metrics resources my-app --since 24h + cerebrium metrics resources my-app --start 2026-08-01T00:00:00Z --end 2026-08-02T00:00:00Z + cerebrium metrics resources my-app --container-id + cerebrium metrics resources my-app --output json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runResources(cmd, args[0], flags) + }, + } + + cmd.Flags().DurationVar(&flags.since, "since", time.Hour, "Window ending now (ignored if --start is set)") + cmd.Flags().StringVar(&flags.start, "start", "", "Start of the window as an RFC3339 timestamp") + cmd.Flags().StringVar(&flags.end, "end", "", "End of the window as an RFC3339 timestamp (defaults to now)") + cmd.Flags().StringVar(&flags.containerID, "container-id", "", "Scope metrics to a single container") + cmd.Flags().StringVar(&flags.resolution, "resolution", "", "Data resolution: medium, high") + ui.AddOutputFlag(cmd) + + return cmd +} + +func runResources(cmd *cobra.Command, appName string, flags resourceFlags) error { + cmd.SilenceUsage = true + + outputFormat, err := ui.ParseOutputFormat(cmd) + if err != nil { + return err + } + + if flags.resolution != "" && flags.resolution != "medium" && flags.resolution != "high" { + return ui.NewValidationError(fmt.Errorf("invalid resolution: %s (supported: medium, high)", flags.resolution)) + } + + start, end, err := resolveWindow(flags) + if err != nil { + return ui.NewValidationError(err) + } + + cfg, err := config.GetConfigFromContext(cmd) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to get config: %w", err)) + } + + projectID, err := cfg.GetCurrentProject() + if err != nil { + return ui.NewValidationError(fmt.Errorf("no project selected: %w", err)) + } + + client, err := api.NewClient(cfg) + if err != nil { + return ui.NewValidationError(fmt.Errorf("failed to create API client: %w", err)) + } + + appID := api.NormalizeAppID(projectID, appName) + + spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading metrics...") + spinner.Start() + + metrics, err := client.GetResourceMetrics(cmd.Context(), projectID, appID, api.ResourceMetricsOptions{ + Start: start, + End: end, + ContainerID: flags.containerID, + Resolution: flags.resolution, + }) + spinner.Stop() + if err != nil { + return ui.NewAPIError(err) + } + + summary := map[string]metricSummary{ + "cpu": summarise(metrics.CPU, "cores"), + "memory": summarise(metrics.Memory, "GB"), + "gpu": summarise(metrics.GPU, "GB"), + } + + if outputFormat == ui.OutputJSON { + return ui.PrintJSON(metricsOutput{ + AppID: appID, + Start: start, + End: end, + ContainerID: flags.containerID, + Summary: summary, + Metrics: metrics, + }) + } + + printSummary(appID, start, end, flags.containerID, summary) + return nil +} + +// resolveWindow turns the time flags into a concrete range. --start wins over +// --since; --end defaults to now. +func resolveWindow(flags resourceFlags) (time.Time, time.Time, error) { + end := time.Now().UTC() + if flags.end != "" { + parsed, err := time.Parse(time.RFC3339, flags.end) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --end timestamp %q: expected RFC3339 (e.g. 2026-08-01T00:00:00Z)", flags.end) + } + end = parsed.UTC() + } + + var start time.Time + if flags.start != "" { + parsed, err := time.Parse(time.RFC3339, flags.start) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --start timestamp %q: expected RFC3339 (e.g. 2026-08-01T00:00:00Z)", flags.start) + } + start = parsed.UTC() + } else { + if flags.since <= 0 { + return time.Time{}, time.Time{}, fmt.Errorf("--since must be positive, got %s", flags.since) + } + start = end.Add(-flags.since) + } + + if !end.After(start) { + return time.Time{}, time.Time{}, fmt.Errorf("end of window (%s) must be after its start (%s)", end.Format(time.RFC3339), start.Format(time.RFC3339)) + } + + return start, end, nil +} + +// summarise reduces each series to its peak over the window. Padded gaps are nil +// and are skipped, so a series that never reported stays nil rather than becoming 0. +func summarise(data api.ChartData, unit string) metricSummary { + out := metricSummary{Unit: unit, Series: make([]seriesPeak, 0, len(data.Series))} + + for _, series := range data.Series { + entry := seriesPeak{Name: series.Name} + for _, sample := range series.Data { + if !sample.Valid { + continue + } + if entry.Peak == nil || sample.Value > *entry.Peak { + peak := sample.Value + entry.Peak = &peak + } + } + out.Series = append(out.Series, entry) + } + + return out +} + +var metricRows = []struct { + key string + label string +}{ + {"cpu", "CPU"}, + {"memory", "Memory"}, + {"gpu", "GPU Memory"}, +} + +func printSummary(appID string, start, end time.Time, containerID string, summary map[string]metricSummary) { + scope := "app" + if containerID != "" { + scope = "container " + containerID + } + fmt.Printf("Peak usage for %s (%s) from %s to %s\n\n", + appID, scope, start.Format(time.RFC3339), end.Format(time.RFC3339)) + + columns := summaryColumns(summary) + if len(columns) == 0 { + fmt.Println("No metrics reported for this window. The app may not have run during it.") + return + } + + fmt.Printf("%-14s", "METRIC") + for _, column := range columns { + fmt.Printf("%10s", column) + } + fmt.Printf(" %s\n", "UNIT") + + for _, row := range metricRows { + metric := summary[row.key] + fmt.Printf("%-14s", row.label) + for i := range columns { + fmt.Printf("%10s", formatPeak(metric, i)) + } + fmt.Printf(" %s\n", metric.Unit) + } +} + +// summaryColumns derives the header from the first metric that reported anything. +// Every metric carries the same series in the same order for a given scope, so +// cells are matched to columns by position. +func summaryColumns(summary map[string]metricSummary) []string { + for _, row := range metricRows { + series := summary[row.key].Series + if len(series) == 0 { + continue + } + + // Scoped to a single container the API returns one series per metric, named + // after the metric itself — a poor shared header, so label the column PEAK. + if len(series) == 1 { + return []string{"PEAK"} + } + + columns := make([]string, 0, len(series)) + for _, entry := range series { + columns = append(columns, entry.Name) + } + return columns + } + return nil +} + +// formatPeak renders one cell. A metric that never reported shows "-" rather than +// 0.00, which would read as a measurement rather than an absence of one. +func formatPeak(metric metricSummary, column int) string { + if column >= len(metric.Series) || metric.Series[column].Peak == nil { + return "-" + } + return fmt.Sprintf("%.2f", *metric.Series[column].Peak) +} diff --git a/internal/commands/metrics/resources_test.go b/internal/commands/metrics/resources_test.go new file mode 100644 index 0000000..c413b19 --- /dev/null +++ b/internal/commands/metrics/resources_test.go @@ -0,0 +1,132 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/cerebriumai/cerebrium/internal/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sample(value float64) api.MetricValue { + return api.MetricValue{Value: value, Valid: true} +} + +func TestSummarise(t *testing.T) { + data := api.ChartData{ + Timestamps: []int64{1, 2, 3}, + Series: []api.ChartSeries{ + {Name: "Max", Data: []api.MetricValue{sample(1.5), {}, sample(3.25)}}, + {Name: "P50", Data: []api.MetricValue{{}, {}, {}}}, + }, + } + + got := summarise(data, "cores") + + assert.Equal(t, "cores", got.Unit) + require.Len(t, got.Series, 2) + + assert.Equal(t, "Max", got.Series[0].Name) + require.NotNil(t, got.Series[0].Peak) + assert.Equal(t, 3.25, *got.Series[0].Peak) + + // A series of nothing but padding has no peak — not a peak of zero. + assert.Equal(t, "P50", got.Series[1].Name) + assert.Nil(t, got.Series[1].Peak) +} + +func TestSummariseKeepsRealZero(t *testing.T) { + data := api.ChartData{ + Series: []api.ChartSeries{{Name: "Max", Data: []api.MetricValue{sample(0)}}}, + } + + got := summarise(data, "GB") + + require.NotNil(t, got.Series[0].Peak) + assert.Equal(t, float64(0), *got.Series[0].Peak) +} + +func TestFormatPeak(t *testing.T) { + peak := 2.5 + metric := metricSummary{Series: []seriesPeak{ + {Name: "Max", Peak: &peak}, + {Name: "P50", Peak: nil}, + }} + + assert.Equal(t, "2.50", formatPeak(metric, 0)) + assert.Equal(t, "-", formatPeak(metric, 1)) + assert.Equal(t, "-", formatPeak(metric, 2), "a metric with fewer series than columns") +} + +func TestSummaryColumns(t *testing.T) { + t.Run("app scope uses the percentile series names", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: []seriesPeak{{Name: "Max"}, {Name: "P50"}, {Name: "P90"}}}, + } + assert.Equal(t, []string{"Max", "P50", "P90"}, summaryColumns(summary)) + }) + + // Scoped to a container the API names each metric's sole series after the + // metric, so the header must not be borrowed from whichever metric came first. + t.Run("container scope collapses to one PEAK column", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: []seriesPeak{{Name: "CPU"}}}, + "memory": {Series: []seriesPeak{{Name: "Memory"}}}, + } + assert.Equal(t, []string{"PEAK"}, summaryColumns(summary)) + }) + + t.Run("skips metrics that reported nothing", func(t *testing.T) { + summary := map[string]metricSummary{ + "cpu": {Series: nil}, + "memory": {Series: []seriesPeak{{Name: "Max"}, {Name: "P50"}}}, + } + assert.Equal(t, []string{"Max", "P50"}, summaryColumns(summary)) + }) + + t.Run("no data at all yields no columns", func(t *testing.T) { + assert.Empty(t, summaryColumns(map[string]metricSummary{"cpu": {}})) + }) +} + +func TestResolveWindow(t *testing.T) { + t.Run("since is measured back from now", func(t *testing.T) { + before := time.Now().UTC() + start, end, err := resolveWindow(resourceFlags{since: 2 * time.Hour}) + require.NoError(t, err) + + assert.WithinDuration(t, before, end, time.Minute) + assert.WithinDuration(t, before.Add(-2*time.Hour), start, time.Minute) + }) + + t.Run("explicit start wins over since", func(t *testing.T) { + start, end, err := resolveWindow(resourceFlags{ + since: time.Hour, + start: "2026-08-01T00:00:00Z", + end: "2026-08-02T00:00:00Z", + }) + require.NoError(t, err) + + assert.Equal(t, "2026-08-01T00:00:00Z", start.Format(time.RFC3339)) + assert.Equal(t, "2026-08-02T00:00:00Z", end.Format(time.RFC3339)) + }) + + t.Run("rejects a backwards window", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{ + start: "2026-08-02T00:00:00Z", + end: "2026-08-01T00:00:00Z", + }) + assert.ErrorContains(t, err, "must be after") + }) + + t.Run("rejects a non-positive since", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{since: 0}) + assert.ErrorContains(t, err, "must be positive") + }) + + t.Run("rejects a malformed timestamp", func(t *testing.T) { + _, _, err := resolveWindow(resourceFlags{start: "yesterday"}) + assert.ErrorContains(t, err, "RFC3339") + }) +} diff --git a/internal/commands/metrics/root.go b/internal/commands/metrics/root.go new file mode 100644 index 0000000..a3cfe4d --- /dev/null +++ b/internal/commands/metrics/root.go @@ -0,0 +1,17 @@ +package metrics + +import ( + "github.com/spf13/cobra" +) + +func NewMetricsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics", + Short: "Inspect resource usage for an app", + Long: "Commands for inspecting how much hardware your Cerebrium apps are actually using", + } + + cmd.AddCommand(newResourcesCmd()) + + return cmd +} diff --git a/internal/commands/root.go b/internal/commands/root.go index f1f5375..656a140 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -10,6 +10,7 @@ import ( configCmd "github.com/cerebriumai/cerebrium/internal/commands/config" containersCmd "github.com/cerebriumai/cerebrium/internal/commands/containers" filesCmd "github.com/cerebriumai/cerebrium/internal/commands/files" + metricsCmd "github.com/cerebriumai/cerebrium/internal/commands/metrics" projectCmd "github.com/cerebriumai/cerebrium/internal/commands/projects" regionCmd "github.com/cerebriumai/cerebrium/internal/commands/region" runsCmd "github.com/cerebriumai/cerebrium/internal/commands/runs" @@ -111,6 +112,7 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(NewStatusCmd()) rootCmd.AddCommand(NewLogsCmd()) rootCmd.AddCommand(containersCmd.NewContainersCmd()) + rootCmd.AddCommand(metricsCmd.NewMetricsCmd()) rootCmd.AddCommand(NewVersionCmd()) rootCmd.AddCommand(configCmd.NewConfigCmd()) rootCmd.AddCommand(appCmd.NewAppsCmd()) diff --git a/internal/commands/runs/list.go b/internal/commands/runs/list.go index e371342..4a5b7d9 100644 --- a/internal/commands/runs/list.go +++ b/internal/commands/runs/list.go @@ -3,7 +3,6 @@ package runs import ( "fmt" "sort" - "strings" "github.com/cerebriumai/cerebrium/internal/api" "github.com/cerebriumai/cerebrium/internal/ui" @@ -62,7 +61,7 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { } // Construct the app ID - appID := normalizeAppID(projectID, appName) + appID := api.NormalizeAppID(projectID, appName) // Show spinner while fetching spinner := ui.NewSimpleSpinnerFor(outputFormat, "Loading runs...") @@ -112,12 +111,3 @@ func runList(cmd *cobra.Command, appName string, asyncOnly bool) error { return nil } - -// normalizeAppID ensures the app ID has the correct format. -func normalizeAppID(projectID, appName string) string { - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - return fmt.Sprintf("%s-%s", projectID, appName) -} diff --git a/internal/ui/commands/runs/list.go b/internal/ui/commands/runs/list.go index 578a09c..f195449 100644 --- a/internal/ui/commands/runs/list.go +++ b/internal/ui/commands/runs/list.go @@ -271,7 +271,7 @@ func (m *ListView) fetchRuns() tea.Msg { // Construct the app ID, handling both formats: // - "5-dockerfile" -> "dev-p-0780791d-5-dockerfile" // - "dev-p-0780791d-5-dockerfile" -> "dev-p-0780791d-5-dockerfile" - appID := normalizeAppID(m.conf.ProjectID, m.conf.AppName) + appID := api.NormalizeAppID(m.conf.ProjectID, m.conf.AppName) // Call the API to fetch runs runs, err := m.conf.Client.GetRuns(m.ctx, m.conf.ProjectID, appID, m.conf.AsyncOnly) @@ -283,19 +283,6 @@ func (m *ListView) fetchRuns() tea.Msg { // Utils -// normalizeAppID ensures the app ID has the correct format. -// If the appName already starts with the projectID prefix, use it as-is. -// Otherwise, prepend the projectID. -func normalizeAppID(projectID, appName string) string { - // Check if appName already has the project ID prefix - expectedPrefix := projectID + "-" - if strings.HasPrefix(appName, expectedPrefix) { - return appName - } - // Prepend the project ID - return fmt.Sprintf("%s-%s", projectID, appName) -} - func newTable(rows []table.Row) table.Model { // Calculate dynamic column widths based on content // Add padding for potential ANSI codes and better spacing diff --git a/internal/ui/commands/runs/list_test.go b/internal/ui/commands/runs/list_test.go index fb61060..3842678 100644 --- a/internal/ui/commands/runs/list_test.go +++ b/internal/ui/commands/runs/list_test.go @@ -354,62 +354,3 @@ func TestRunsListView(t *testing.T) { Run(t) }) } - -func Test_normalizeAppID(t *testing.T) { - tcs := []struct { - name string - projectID string - appName string - expected string - }{ - { - name: "app name without project prefix", - projectID: "dev-p-0780791d", - appName: "5-dockerfile", - expected: "dev-p-0780791d-5-dockerfile", - }, - { - name: "app name with project prefix", - projectID: "dev-p-0780791d", - appName: "dev-p-0780791d-5-dockerfile", - expected: "dev-p-0780791d-5-dockerfile", - }, - { - name: "app name with partial match", - projectID: "dev-p-0780791d", - appName: "dev-p-123-myapp", - expected: "dev-p-0780791d-dev-p-123-myapp", - }, - { - name: "simple app name", - projectID: "project-123", - appName: "myapp", - expected: "project-123-myapp", - }, - { - name: "app name already has full ID", - projectID: "project-123", - appName: "project-123-myapp-v2", - expected: "project-123-myapp-v2", - }, - { - name: "edge case - empty app name", - projectID: "project-123", - appName: "", - expected: "project-123-", - }, - { - name: "edge case - app name with only dash", - projectID: "project-123", - appName: "-test", - expected: "project-123--test", - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - result := normalizeAppID(tc.projectID, tc.appName) - assert.Equal(t, tc.expected, result) - }) - } -}