diff --git a/README.md b/README.md index 3a4adf1c..4083ce36 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,8 @@ claws -l debug.log | `d` | Describe (or diff if marked) | | `c` | Clear filter and mark | | `N` | Load next page (pagination) | -| `Ctrl+r` | Refresh | +| `M` | Toggle inline metrics (EC2, RDS, Lambda) | +| `Ctrl+r` | Refresh (including metrics) | | `R` | Switch AWS region | | `P` | Switch AWS profile | | `?` | Show help | @@ -356,6 +357,8 @@ claws uses your standard AWS configuration: Configuration is stored in `~/.config/claws/config.yaml` for profile preferences. +For required IAM permissions, see [docs/iam-permissions.md](docs/iam-permissions.md). + ## Architecture claws uses a simple architecture with custom implementations for each service: diff --git a/custom/ec2/instances/render.go b/custom/ec2/instances/render.go index fb239aa6..453e6297 100644 --- a/custom/ec2/instances/render.go +++ b/custom/ec2/instances/render.go @@ -9,8 +9,10 @@ import ( "github.com/clawscli/claws/internal/render" ) -// Ensure InstanceRenderer implements render.Navigator -var _ render.Navigator = (*InstanceRenderer)(nil) +var ( + _ render.Navigator = (*InstanceRenderer)(nil) + _ render.MetricSpecProvider = (*InstanceRenderer)(nil) +) // InstanceRenderer renders EC2 instances with custom columns type InstanceRenderer struct { @@ -367,3 +369,14 @@ func (r *InstanceRenderer) Navigations(resource dao.Resource) []render.Navigatio return navs } + +func (r *InstanceRenderer) MetricSpec() *render.MetricSpec { + return &render.MetricSpec{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionName: "InstanceId", + Stat: "Average", + ColumnHeader: "CPU(15m)", + Unit: "%", + } +} diff --git a/custom/lambda/functions/render.go b/custom/lambda/functions/render.go index 7020fb05..79b3c053 100644 --- a/custom/lambda/functions/render.go +++ b/custom/lambda/functions/render.go @@ -11,8 +11,10 @@ import ( ) // FunctionRenderer renders Lambda functions -// Ensure FunctionRenderer implements render.Navigator -var _ render.Navigator = (*FunctionRenderer)(nil) +var ( + _ render.Navigator = (*FunctionRenderer)(nil) + _ render.MetricSpecProvider = (*FunctionRenderer)(nil) +) type FunctionRenderer struct { render.BaseRenderer @@ -369,3 +371,14 @@ func (r *FunctionRenderer) Navigations(resource dao.Resource) []render.Navigatio return navs } + +func (r *FunctionRenderer) MetricSpec() *render.MetricSpec { + return &render.MetricSpec{ + Namespace: "AWS/Lambda", + MetricName: "Invocations", + DimensionName: "FunctionName", + Stat: "Sum", + ColumnHeader: "INVOC(15m)", + Unit: "", + } +} diff --git a/custom/rds/instances/render.go b/custom/rds/instances/render.go index 3cf1bb10..b8886992 100644 --- a/custom/rds/instances/render.go +++ b/custom/rds/instances/render.go @@ -9,8 +9,10 @@ import ( "github.com/clawscli/claws/internal/render" ) -// Ensure InstanceRenderer implements render.Navigator -var _ render.Navigator = (*InstanceRenderer)(nil) +var ( + _ render.Navigator = (*InstanceRenderer)(nil) + _ render.MetricSpecProvider = (*InstanceRenderer)(nil) +) // InstanceRenderer renders RDS instances with custom columns type InstanceRenderer struct { @@ -303,3 +305,14 @@ func (r *InstanceRenderer) Navigations(resource dao.Resource) []render.Navigatio return navs } + +func (r *InstanceRenderer) MetricSpec() *render.MetricSpec { + return &render.MetricSpec{ + Namespace: "AWS/RDS", + MetricName: "CPUUtilization", + DimensionName: "DBInstanceIdentifier", + Stat: "Average", + ColumnHeader: "CPU(15m)", + Unit: "%", + } +} diff --git a/docs/iam-permissions.md b/docs/iam-permissions.md new file mode 100644 index 00000000..64ce2a41 --- /dev/null +++ b/docs/iam-permissions.md @@ -0,0 +1,69 @@ +# IAM Permissions + +claws requires appropriate IAM permissions to access AWS resources. The permissions needed depend on which services you want to browse. + +## Minimum Permissions + +For basic read-only browsing, claws needs `Describe*`, `List*`, and `Get*` permissions for the services you want to access. + +## Inline Metrics (Optional) + +To display inline CloudWatch metrics (toggle with `M` key), you need: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "cloudwatch:GetMetricData", + "Resource": "*" + } + ] +} +``` + +Metrics are disabled by default. When enabled, claws fetches the last hour of metrics for supported resources (EC2, RDS, Lambda). + +## Resource Actions + +Some resource actions require additional permissions: + +| Action | Permission Required | +|--------|---------------------| +| Start/Stop EC2 | `ec2:StartInstances`, `ec2:StopInstances` | +| Delete resources | `:Delete*` | +| SSO Login | `sso:*` (for SSO profiles) | + +## Recommended Policy + +For full read-only access with metrics: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:Describe*", + "rds:Describe*", + "lambda:List*", + "lambda:Get*", + "s3:List*", + "s3:GetBucket*", + "cloudwatch:GetMetricData", + "iam:List*", + "iam:Get*" + ], + "Resource": "*" + } + ] +} +``` + +For full access, use AWS managed policies like `ReadOnlyAccess` or `ViewOnlyAccess`. + +## Read-Only Mode + +Run claws with `--read-only` or set `CLAWS_READ_ONLY=1` to disable all destructive actions, regardless of IAM permissions. diff --git a/go.mod b/go.mod index d21ebd2f..3c1bc4d5 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.4 github.com/aws/aws-sdk-go-v2/service/cloudfront v1.58.3 github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.4 + github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.53.0 github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.62.2 github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.8 github.com/aws/aws-sdk-go-v2/service/codepipeline v1.46.16 @@ -82,6 +83,7 @@ require ( github.com/creack/pty v1.1.24 github.com/mattn/go-runewidth v0.0.19 github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.19.0 golang.org/x/term v0.38.0 gopkg.in/ini.v1 v1.67.0 ) @@ -95,7 +97,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.53.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect @@ -121,7 +122,6 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/metrics/cloudwatch.go b/internal/metrics/cloudwatch.go new file mode 100644 index 00000000..4b64a666 --- /dev/null +++ b/internal/metrics/cloudwatch.go @@ -0,0 +1,121 @@ +// Package metrics provides CloudWatch metrics fetching and sparkline rendering. +package metrics + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/render" +) + +const ( + metricPeriod = 60 + metricWindow = 15 * time.Minute + maxQueriesPerRequest = 500 +) + +type Fetcher struct { + client *cloudwatch.Client +} + +func NewFetcher(ctx context.Context) (*Fetcher, error) { + cfg, err := appaws.NewConfig(ctx) + if err != nil { + return nil, err + } + return &Fetcher{client: cloudwatch.NewFromConfig(cfg)}, nil +} + +func (f *Fetcher) Fetch(ctx context.Context, resourceIDs []string, spec *render.MetricSpec) (*MetricData, error) { + if len(resourceIDs) == 0 || spec == nil { + return NewMetricData(spec), nil + } + + queries := f.buildQueries(resourceIDs, spec) + endTime := time.Now().Truncate(time.Minute) + startTime := endTime.Add(-metricWindow) + + data := NewMetricData(spec) + + for i := 0; i < len(queries); i += maxQueriesPerRequest { + if ctx.Err() != nil { + return data, ctx.Err() + } + + end := i + maxQueriesPerRequest + if end > len(queries) { + end = len(queries) + } + batch := queries[i:end] + + input := &cloudwatch.GetMetricDataInput{ + StartTime: aws.Time(startTime), + EndTime: aws.Time(endTime), + MetricDataQueries: batch, + ScanBy: types.ScanByTimestampAscending, + } + + output, err := f.client.GetMetricData(ctx, input) + if err != nil { + return nil, fmt.Errorf("GetMetricData failed: %w", err) + } + + f.processResults(output.MetricDataResults, resourceIDs, data) + } + + return data, nil +} + +func (f *Fetcher) buildQueries(resourceIDs []string, spec *render.MetricSpec) []types.MetricDataQuery { + queries := make([]types.MetricDataQuery, len(resourceIDs)) + for i, resourceID := range resourceIDs { + queries[i] = types.MetricDataQuery{ + Id: aws.String(fmt.Sprintf("m%d", i)), + MetricStat: &types.MetricStat{ + Metric: &types.Metric{ + Namespace: aws.String(spec.Namespace), + MetricName: aws.String(spec.MetricName), + Dimensions: []types.Dimension{ + { + Name: aws.String(spec.DimensionName), + Value: aws.String(resourceID), + }, + }, + }, + Period: aws.Int32(metricPeriod), + Stat: aws.String(spec.Stat), + }, + } + } + return queries +} + +func (f *Fetcher) processResults(results []types.MetricDataResult, resourceIDs []string, data *MetricData) { + idToResource := make(map[string]string, len(resourceIDs)) + for i, id := range resourceIDs { + idToResource[fmt.Sprintf("m%d", i)] = id + } + + for _, result := range results { + queryID := aws.ToString(result.Id) + resourceID, ok := idToResource[queryID] + if !ok { + continue + } + + metricResult := &MetricResult{ + ResourceID: resourceID, + Values: result.Values, + HasData: len(result.Values) > 0, + } + if metricResult.HasData { + metricResult.Latest = result.Values[len(result.Values)-1] + } + data.Results[resourceID] = metricResult + } +} diff --git a/internal/metrics/cloudwatch_test.go b/internal/metrics/cloudwatch_test.go new file mode 100644 index 00000000..2195e58f --- /dev/null +++ b/internal/metrics/cloudwatch_test.go @@ -0,0 +1,172 @@ +package metrics + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + "github.com/clawscli/claws/internal/render" +) + +func TestFetcher_buildQueries(t *testing.T) { + f := &Fetcher{} + spec := &render.MetricSpec{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionName: "InstanceId", + Stat: "Average", + } + + tests := []struct { + name string + resourceIDs []string + wantLen int + }{ + {"empty", []string{}, 0}, + {"single", []string{"i-123"}, 1}, + {"multiple", []string{"i-1", "i-2", "i-3"}, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queries := f.buildQueries(tt.resourceIDs, spec) + if len(queries) != tt.wantLen { + t.Errorf("buildQueries() len = %d, want %d", len(queries), tt.wantLen) + } + }) + } +} + +func TestFetcher_buildQueries_correctStructure(t *testing.T) { + f := &Fetcher{} + spec := &render.MetricSpec{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionName: "InstanceId", + Stat: "Average", + } + + queries := f.buildQueries([]string{"i-abc123"}, spec) + if len(queries) != 1 { + t.Fatalf("expected 1 query, got %d", len(queries)) + } + + q := queries[0] + if *q.Id != "m0" { + t.Errorf("Id = %s, want m0", *q.Id) + } + if q.MetricStat == nil { + t.Fatal("MetricStat is nil") + } + if *q.MetricStat.Metric.Namespace != "AWS/EC2" { + t.Errorf("Namespace = %s, want AWS/EC2", *q.MetricStat.Metric.Namespace) + } + if *q.MetricStat.Metric.MetricName != "CPUUtilization" { + t.Errorf("MetricName = %s, want CPUUtilization", *q.MetricStat.Metric.MetricName) + } + if len(q.MetricStat.Metric.Dimensions) != 1 { + t.Fatalf("Dimensions len = %d, want 1", len(q.MetricStat.Metric.Dimensions)) + } + if *q.MetricStat.Metric.Dimensions[0].Name != "InstanceId" { + t.Errorf("Dimension name = %s, want InstanceId", *q.MetricStat.Metric.Dimensions[0].Name) + } + if *q.MetricStat.Metric.Dimensions[0].Value != "i-abc123" { + t.Errorf("Dimension value = %s, want i-abc123", *q.MetricStat.Metric.Dimensions[0].Value) + } +} + +func TestBatchSplitting(t *testing.T) { + tests := []struct { + name string + total int + batchSize int + wantBatches int + }{ + {"under limit", 100, 500, 1}, + {"at limit", 500, 500, 1}, + {"over limit", 501, 500, 2}, + {"double", 1000, 500, 2}, + {"triple", 1200, 500, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + batches := 0 + for i := 0; i < tt.total; i += tt.batchSize { + batches++ + } + if batches != tt.wantBatches { + t.Errorf("batches = %d, want %d", batches, tt.wantBatches) + } + }) + } +} + +func TestProcessResults(t *testing.T) { + f := &Fetcher{} + resourceIDs := []string{"i-1", "i-2", "i-3"} + data := NewMetricData(nil) + + f.processResults(nil, resourceIDs, data) + if len(data.Results) != 0 { + t.Errorf("expected 0 results, got %d", len(data.Results)) + } +} + +func TestProcessResults_WithData(t *testing.T) { + f := &Fetcher{} + resourceIDs := []string{"i-abc", "i-def", "i-ghi"} + data := NewMetricData(nil) + + results := []types.MetricDataResult{ + {Id: aws.String("m0"), Values: []float64{10.0, 20.0, 30.0}}, + {Id: aws.String("m1"), Values: []float64{5.0, 15.0}}, + {Id: aws.String("m2"), Values: []float64{}}, + } + + f.processResults(results, resourceIDs, data) + + if len(data.Results) != 3 { + t.Fatalf("expected 3 results, got %d", len(data.Results)) + } + + r0 := data.Results["i-abc"] + if r0 == nil { + t.Fatal("i-abc not found") + } + if !r0.HasData || r0.Latest != 30.0 || len(r0.Values) != 3 { + t.Errorf("i-abc: HasData=%v, Latest=%v, len=%d", r0.HasData, r0.Latest, len(r0.Values)) + } + + r1 := data.Results["i-def"] + if r1 == nil { + t.Fatal("i-def not found") + } + if !r1.HasData || r1.Latest != 15.0 { + t.Errorf("i-def: HasData=%v, Latest=%v", r1.HasData, r1.Latest) + } + + r2 := data.Results["i-ghi"] + if r2 == nil { + t.Fatal("i-ghi not found") + } + if r2.HasData { + t.Errorf("i-ghi should have no data") + } +} + +func TestProcessResults_UnknownQueryID(t *testing.T) { + f := &Fetcher{} + resourceIDs := []string{"i-abc"} + data := NewMetricData(nil) + + results := []types.MetricDataResult{ + {Id: aws.String("m99"), Values: []float64{100.0}}, + } + + f.processResults(results, resourceIDs, data) + + if len(data.Results) != 0 { + t.Errorf("expected 0 results for unknown query ID, got %d", len(data.Results)) + } +} diff --git a/internal/metrics/sparkline.go b/internal/metrics/sparkline.go new file mode 100644 index 00000000..690227cc --- /dev/null +++ b/internal/metrics/sparkline.go @@ -0,0 +1,59 @@ +package metrics + +import ( + "fmt" + "math" +) + +var sparkBlocks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +const ( + SparklineWidth = 7 + ColumnWidth = 13 + noDataPlaceholder = "·······" +) + +// RenderSparkline renders a sparkline with the latest value and optional unit suffix. +func RenderSparkline(result *MetricResult, unit string) string { + if result == nil || !result.HasData || len(result.Values) == 0 { + return fmt.Sprintf("%s -", noDataPlaceholder) + } + + values := result.Values + if len(values) > SparklineWidth { + values = values[len(values)-SparklineWidth:] + } + + minVal, maxVal := values[0], values[0] + for _, v := range values { + if v < minVal { + minVal = v + } + if v > maxVal { + maxVal = v + } + } + + var spark string + valRange := maxVal - minVal + for _, v := range values { + idx := 0 + if valRange > 0 { + normalized := (v - minVal) / valRange + idx = int(math.Round(normalized * float64(len(sparkBlocks)-1))) + } + if idx < 0 { + idx = 0 + } + if idx >= len(sparkBlocks) { + idx = len(sparkBlocks) - 1 + } + spark += string(sparkBlocks[idx]) + } + + for len(spark) < SparklineWidth { + spark = "·" + spark + } + + return fmt.Sprintf("%s %3.0f%s", spark, result.Latest, unit) +} diff --git a/internal/metrics/sparkline_test.go b/internal/metrics/sparkline_test.go new file mode 100644 index 00000000..74f72cba --- /dev/null +++ b/internal/metrics/sparkline_test.go @@ -0,0 +1,94 @@ +package metrics + +import ( + "strings" + "testing" +) + +func TestRenderSparkline_Nil(t *testing.T) { + result := RenderSparkline(nil, "%") + if result != "······· -" { + t.Errorf("RenderSparkline(nil) = %q, want %q", result, "······· -") + } +} + +func TestRenderSparkline_NoData(t *testing.T) { + result := RenderSparkline(&MetricResult{HasData: false}, "%") + if result != "······· -" { + t.Errorf("RenderSparkline(no data) = %q, want %q", result, "······· -") + } +} + +func TestRenderSparkline_EmptyValues(t *testing.T) { + result := RenderSparkline(&MetricResult{HasData: true, Values: []float64{}}, "%") + if result != "······· -" { + t.Errorf("RenderSparkline(empty) = %q, want %q", result, "······· -") + } +} + +func TestRenderSparkline_SingleValue(t *testing.T) { + result := RenderSparkline(&MetricResult{ + HasData: true, + Values: []float64{50.0}, + Latest: 50.0, + }, "%") + if !strings.HasSuffix(result, " 50%") { + t.Errorf("RenderSparkline(single) = %q, want suffix ' 50%%'", result) + } +} + +func TestRenderSparkline_MultipleValues(t *testing.T) { + result := RenderSparkline(&MetricResult{ + HasData: true, + Values: []float64{0, 25, 50, 75, 100}, + Latest: 100.0, + }, "%") + if !strings.HasSuffix(result, "100%") { + t.Errorf("RenderSparkline(multi) = %q, want suffix '100%%'", result) + } + if !strings.ContainsAny(result, "▁▂▃▄▅▆▇█") { + t.Errorf("RenderSparkline(multi) = %q, want sparkline chars", result) + } +} + +func TestRenderSparkline_ConstantValues(t *testing.T) { + result := RenderSparkline(&MetricResult{ + HasData: true, + Values: []float64{50, 50, 50, 50, 50}, + Latest: 50.0, + }, "%") + if !strings.HasSuffix(result, " 50%") { + t.Errorf("RenderSparkline(constant) = %q, want suffix ' 50%%'", result) + } +} + +func TestRenderSparkline_TruncatesToWidth(t *testing.T) { + values := make([]float64, 20) + for i := range values { + values[i] = float64(i * 5) + } + result := RenderSparkline(&MetricResult{ + HasData: true, + Values: values, + Latest: 95.0, + }, "%") + parts := strings.Split(result, " ") + sparkline := parts[0] + if len([]rune(sparkline)) != SparklineWidth { + t.Errorf("sparkline width = %d, want %d", len([]rune(sparkline)), SparklineWidth) + } +} + +func TestRenderSparkline_EmptyUnit(t *testing.T) { + result := RenderSparkline(&MetricResult{ + HasData: true, + Values: []float64{100, 200, 300}, + Latest: 300.0, + }, "") + if !strings.HasSuffix(result, "300") { + t.Errorf("RenderSparkline(empty unit) = %q, want suffix '300'", result) + } + if strings.HasSuffix(result, "300%") { + t.Errorf("RenderSparkline(empty unit) = %q, should not have %%", result) + } +} diff --git a/internal/metrics/types.go b/internal/metrics/types.go new file mode 100644 index 00000000..1ea4682a --- /dev/null +++ b/internal/metrics/types.go @@ -0,0 +1,31 @@ +package metrics + +import "github.com/clawscli/claws/internal/render" + +// MetricResult holds metric data for a single resource. +type MetricResult struct { + ResourceID string + Values []float64 + Latest float64 + HasData bool +} + +// MetricData holds metric results for multiple resources. +type MetricData struct { + Results map[string]*MetricResult + Spec *render.MetricSpec +} + +func NewMetricData(spec *render.MetricSpec) *MetricData { + return &MetricData{ + Results: make(map[string]*MetricResult), + Spec: spec, + } +} + +func (m *MetricData) Get(resourceID string) *MetricResult { + if m == nil || m.Results == nil { + return nil + } + return m.Results[resourceID] +} diff --git a/internal/metrics/types_test.go b/internal/metrics/types_test.go new file mode 100644 index 00000000..1b6b3873 --- /dev/null +++ b/internal/metrics/types_test.go @@ -0,0 +1,38 @@ +package metrics + +import "testing" + +func TestMetricData_Get_Nil(t *testing.T) { + var data *MetricData + result := data.Get("test-id") + if result != nil { + t.Errorf("Get on nil MetricData = %v, want nil", result) + } +} + +func TestMetricData_Get_NilResults(t *testing.T) { + data := &MetricData{Results: nil} + result := data.Get("test-id") + if result != nil { + t.Errorf("Get on nil Results = %v, want nil", result) + } +} + +func TestMetricData_Get_NotFound(t *testing.T) { + data := NewMetricData(nil) + result := data.Get("nonexistent") + if result != nil { + t.Errorf("Get(nonexistent) = %v, want nil", result) + } +} + +func TestMetricData_Get_Found(t *testing.T) { + data := NewMetricData(nil) + expected := &MetricResult{ResourceID: "test-id", HasData: true} + data.Results["test-id"] = expected + + result := data.Get("test-id") + if result != expected { + t.Errorf("Get(test-id) = %v, want %v", result, expected) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 1c8469d4..98fdb018 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -66,6 +66,21 @@ type Navigator interface { Navigations(resource dao.Resource) []Navigation } +// MetricSpecProvider is an optional interface for renderers that support inline metrics. +type MetricSpecProvider interface { + MetricSpec() *MetricSpec +} + +// MetricSpec defines which CloudWatch metric to fetch for inline display. +type MetricSpec struct { + Namespace string + MetricName string + DimensionName string + Stat string + ColumnHeader string + Unit string // Display unit (e.g., "%", "", "ms"). Empty for count-based metrics. +} + // BaseRenderer provides a default implementation type BaseRenderer struct { Service string diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 6b5ad3b2..7f0c4fed 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -15,6 +15,7 @@ import ( "github.com/clawscli/claws/internal/action" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/log" + "github.com/clawscli/claws/internal/metrics" "github.com/clawscli/claws/internal/registry" "github.com/clawscli/claws/internal/render" "github.com/clawscli/claws/internal/ui" @@ -23,8 +24,8 @@ import ( // ResourceBrowser displays resources of a specific type const ( - // logTokenMaxLen is the max length of pagination token shown in debug logs - logTokenMaxLen = 20 + logTokenMaxLen = 20 + metricsLoadTimeout = 30 * time.Second ) // resourceBrowserStyles holds cached lipgloss styles for performance @@ -111,6 +112,11 @@ type ResourceBrowser struct { // Diff mark (for comparing two resources) markedResource dao.Resource + + // Inline metrics + metricsEnabled bool + metricsLoading bool + metricsData *metrics.MetricData } // NewResourceBrowser creates a new ResourceBrowser @@ -299,6 +305,12 @@ type resourcesErrorMsg struct { err error } +type metricsLoadedMsg struct { + data *metrics.MetricData + err error + resourceType string +} + // Update implements tea.Model func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -311,9 +323,16 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.hasMorePages = msg.hasMorePages r.applyFilter() r.buildTable() - // Schedule next tick if auto-reload is enabled + + var cmds []tea.Cmd if r.autoReload { - return r, r.tickCmd() + cmds = append(cmds, r.tickCmd()) + } + if r.metricsEnabled && r.metricsLoading { + cmds = append(cmds, r.loadMetricsCmd()) + } + if len(cmds) > 0 { + return r, tea.Batch(cmds...) } return r, nil @@ -343,8 +362,23 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return r, nil + case metricsLoadedMsg: + r.metricsLoading = false + if msg.resourceType != r.resourceType { + return r, nil + } + if msg.err != nil { + log.Warn("failed to load metrics", "error", msg.err, "service", r.service, "resource", r.resourceType) + } else { + r.metricsData = msg.data + } + r.buildTable() + return r, nil + case autoReloadTickMsg: - // Silent reload (don't show loading state to avoid flicker) + if r.metricsEnabled && r.getMetricSpec() != nil { + return r, tea.Batch(r.reloadResources, r.loadMetricsCmd()) + } return r, r.reloadResources case RefreshMsg: @@ -462,6 +496,10 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+r": r.loading = true r.err = nil + if r.metricsEnabled { + r.metricsLoading = true + r.metricsData = nil + } return r, tea.Batch(r.loadResources, r.spinner.Tick) case "c": r.filterText = "" @@ -490,6 +528,16 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.buildTable() } return r, nil + case "M": + if r.getMetricSpec() != nil { + r.metricsEnabled = !r.metricsEnabled + if r.metricsEnabled && r.metricsData == nil { + r.metricsLoading = true + return r, r.loadMetricsCmd() + } + r.buildTable() + } + return r, nil case "d", "enter": if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { resource := r.filtered[r.table.Cursor()] @@ -530,6 +578,8 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.filterText = "" r.filterInput.SetValue("") r.markedResource = nil + r.metricsEnabled = false + r.metricsData = nil return r, tea.Batch(r.loadResources, r.spinner.Tick) } case "N": @@ -638,6 +688,48 @@ func (r *ResourceBrowser) loadNextPage() tea.Msg { } } +// loadMetricsCmd captures resource IDs and type synchronously before returning the tea.Cmd, +// avoiding race conditions where r.resources could be modified while the goroutine iterates. +func (r *ResourceBrowser) loadMetricsCmd() tea.Cmd { + spec := r.getMetricSpec() + if spec == nil { + return nil + } + + resourceIDs := make([]string, len(r.resources)) + for i, res := range r.resources { + resourceIDs[i] = res.GetID() + } + resourceType := r.resourceType + + return func() tea.Msg { + if r.ctx.Err() != nil { + return nil + } + + ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) + defer cancel() + + fetcher, err := metrics.NewFetcher(ctx) + if err != nil { + return metricsLoadedMsg{err: err, resourceType: resourceType} + } + + data, err := fetcher.Fetch(ctx, resourceIDs, spec) + return metricsLoadedMsg{data: data, err: err, resourceType: resourceType} + } +} + +func (r *ResourceBrowser) getMetricSpec() *render.MetricSpec { + if r.renderer == nil { + return nil + } + if provider, ok := r.renderer.(render.MetricSpecProvider); ok { + return provider.MetricSpec() + } + return nil +} + // buildTable rebuilds the table with current filtered resources func (r *ResourceBrowser) buildTable() { if r.renderer == nil { @@ -647,14 +739,24 @@ func (r *ResourceBrowser) buildTable() { currentCursor := r.table.Cursor() cols := r.renderer.Columns() - const markColWidth = 2 // mark indicator + space (e.g., "◆ ") - tableCols := make([]table.Column, len(cols)+1) + const markColWidth = 2 + metricsColWidth := metrics.ColumnWidth + + effectiveMetricsEnabled := r.metricsEnabled && r.getMetricSpec() != nil + numCols := len(cols) + 1 + if effectiveMetricsEnabled { + numCols++ + } + tableCols := make([]table.Column, numCols) tableCols[0] = table.Column{Title: " ", Width: markColWidth} totalColWidth := markColWidth for _, col := range cols { totalColWidth += col.Width } + if effectiveMetricsEnabled { + totalColWidth += metricsColWidth + } extraWidth := r.width - totalColWidth if extraWidth < 0 { @@ -664,7 +766,7 @@ func (r *ResourceBrowser) buildTable() { for i, col := range cols { title := col.Name + r.getSortIndicator(i) width := col.Width - if i == len(cols)-1 { + if i == len(cols)-1 && !effectiveMetricsEnabled { width += extraWidth } tableCols[i+1] = table.Column{ @@ -673,6 +775,18 @@ func (r *ResourceBrowser) buildTable() { } } + if effectiveMetricsEnabled { + spec := r.getMetricSpec() + header := "METRICS" + if spec != nil { + header = spec.ColumnHeader + } + tableCols[len(cols)+1] = table.Column{ + Title: header, + Width: metricsColWidth + extraWidth, + } + } + rows := make([]table.Row, len(r.filtered)) for i, res := range r.filtered { row := r.renderer.RenderRow(res, cols) @@ -680,9 +794,18 @@ func (r *ResourceBrowser) buildTable() { if r.markedResource != nil && r.markedResource.GetID() == res.GetID() { markIndicator = "◆ " } - fullRow := make(table.Row, len(row)+1) + fullRow := make(table.Row, numCols) fullRow[0] = markIndicator copy(fullRow[1:], row) + if effectiveMetricsEnabled && r.metricsData != nil { + unit := "" + if r.metricsData.Spec != nil { + unit = r.metricsData.Spec.Unit + } + fullRow[len(cols)+1] = metrics.RenderSparkline(r.metricsData.Get(res.GetID()), unit) + } else if effectiveMetricsEnabled { + fullRow[len(cols)+1] = metrics.RenderSparkline(nil, "") + } rows[i] = fullRow } @@ -878,6 +1001,8 @@ func (r *ResourceBrowser) switchToTab(idx int) (tea.Model, tea.Cmd) { } r.resourceType = r.resourceTypes[idx] r.markedResource = nil + r.metricsEnabled = false + r.metricsData = nil return r, r.loadResources } diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index 12e4bda3..85001eed 100644 --- a/internal/view/resource_browser_nav.go +++ b/internal/view/resource_browser_nav.go @@ -45,6 +45,8 @@ func (r *ResourceBrowser) cycleResourceType(delta int) { r.filterText = "" r.filterInput.SetValue("") r.markedResource = nil + r.metricsEnabled = false + r.metricsData = nil } // StatusLine implements View interface @@ -87,12 +89,23 @@ func (r *ResourceBrowser) StatusLine() string { dHint = "d:diff" } + metricsHint := "" + if r.getMetricSpec() != nil { + if r.metricsLoading { + metricsHint = " M:metrics(loading)" + } else if r.metricsEnabled { + metricsHint = " M:metrics(on)" + } else { + metricsHint = " M:metrics" + } + } + if r.filterText != "" || filterInfo != "" { base := fmt.Sprintf("%s/%s%s%s%s%s • %d/%d items • c:clear", r.service, r.resourceType, filterInfo, sortInfo, markInfo, autoReloadInfo, shown, total) if hasActions { base += " a:actions" } - base += " m:mark" + base += " m:mark" + metricsHint if navInfo != "" { base += " " + navInfo } @@ -103,7 +116,7 @@ func (r *ResourceBrowser) StatusLine() string { if hasActions { base += " a:actions" } - base += " m:mark" + base += " m:mark" + metricsHint if navInfo != "" { base += " " + navInfo }