From 46a17dc478779a8430f482a4662d71e5b734294b Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 28 Dec 2025 14:00:01 +0000 Subject: [PATCH 01/12] feat: inline metrics with sparkline display (#32) Add M key toggle to show CloudWatch metrics as sparklines in resource list. - metrics package: CloudWatch GetMetricData batch fetcher, sparkline renderer - MetricSpecProvider interface for resource-specific metric config - EC2 instances: CPUUtilization with 15m window - Ctrl+r reloads metrics when enabled --- custom/ec2/instances/render.go | 16 ++++- internal/metrics/cloudwatch.go | 116 ++++++++++++++++++++++++++++++ internal/metrics/sparkline.go | 57 +++++++++++++++ internal/metrics/types.go | 31 ++++++++ internal/render/render.go | 14 ++++ internal/view/resource_browser.go | 102 ++++++++++++++++++++++++-- 6 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 internal/metrics/cloudwatch.go create mode 100644 internal/metrics/sparkline.go create mode 100644 internal/metrics/types.go diff --git a/custom/ec2/instances/render.go b/custom/ec2/instances/render.go index fb239aa6..bc8aafb3 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,13 @@ 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)", + } +} diff --git a/internal/metrics/cloudwatch.go b/internal/metrics/cloudwatch.go new file mode 100644 index 00000000..7f332e12 --- /dev/null +++ b/internal/metrics/cloudwatch.go @@ -0,0 +1,116 @@ +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 { + 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/sparkline.go b/internal/metrics/sparkline.go new file mode 100644 index 00000000..f74f6b8a --- /dev/null +++ b/internal/metrics/sparkline.go @@ -0,0 +1,57 @@ +package metrics + +import ( + "fmt" + "math" +) + +var sparkBlocks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +const ( + sparklineWidth = 7 + noDataPlaceholder = "·······" +) + +func RenderSparkline(result *MetricResult) 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%%", spark, result.Latest) +} 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/render/render.go b/internal/render/render.go index 1c8469d4..421d8153 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -66,6 +66,20 @@ 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 +} + // 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..ec6b6759 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" @@ -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,11 @@ type resourcesErrorMsg struct { err error } +type metricsLoadedMsg struct { + data *metrics.MetricData + err error +} + // Update implements tea.Model func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -343,6 +354,16 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return r, nil + case metricsLoadedMsg: + r.metricsLoading = false + if msg.err != nil { + log.Warn("failed to load metrics", "error", msg.err) + } else { + r.metricsData = msg.data + } + r.buildTable() + return r, nil + case autoReloadTickMsg: // Silent reload (don't show loading state to avoid flicker) return r, r.reloadResources @@ -462,7 +483,13 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+r": r.loading = true r.err = nil - return r, tea.Batch(r.loadResources, r.spinner.Tick) + cmds := []tea.Cmd{r.loadResources, r.spinner.Tick} + if r.metricsEnabled { + r.metricsLoading = true + r.metricsData = nil + cmds = append(cmds, r.loadMetrics) + } + return r, tea.Batch(cmds...) case "c": r.filterText = "" r.filterInput.SetValue("") @@ -490,6 +517,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.loadMetrics + } + 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()] @@ -638,6 +675,36 @@ func (r *ResourceBrowser) loadNextPage() tea.Msg { } } +func (r *ResourceBrowser) loadMetrics() tea.Msg { + spec := r.getMetricSpec() + if spec == nil { + return metricsLoadedMsg{} + } + + fetcher, err := metrics.NewFetcher(r.ctx) + if err != nil { + return metricsLoadedMsg{err: err} + } + + resourceIDs := make([]string, len(r.resources)) + for i, res := range r.resources { + resourceIDs[i] = res.GetID() + } + + data, err := fetcher.Fetch(r.ctx, resourceIDs, spec) + return metricsLoadedMsg{data: data, err: err} +} + +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 +714,23 @@ 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 + const metricsColWidth = 13 + + numCols := len(cols) + 1 + if r.metricsEnabled { + numCols++ + } + tableCols := make([]table.Column, numCols) tableCols[0] = table.Column{Title: " ", Width: markColWidth} totalColWidth := markColWidth for _, col := range cols { totalColWidth += col.Width } + if r.metricsEnabled { + totalColWidth += metricsColWidth + } extraWidth := r.width - totalColWidth if extraWidth < 0 { @@ -664,7 +740,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 && !r.metricsEnabled { width += extraWidth } tableCols[i+1] = table.Column{ @@ -673,6 +749,18 @@ func (r *ResourceBrowser) buildTable() { } } + if r.metricsEnabled { + 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 +768,13 @@ 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 r.metricsEnabled { + metricResult := r.metricsData.Get(res.GetID()) + fullRow[len(cols)+1] = metrics.RenderSparkline(metricResult) + } rows[i] = fullRow } From 8e65d6b70bfb2641ec62c2d2b8e5f03b5a163f08 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 28 Dec 2025 22:44:13 +0000 Subject: [PATCH 02/12] fix: address code review findings for inline metrics - P0: use effectiveMetricsEnabled to check spec before rendering - P1: load metrics after resources in resourcesLoadedMsg handler - P1: reset metricsEnabled/metricsData on resourceType switch - P1: add M:metrics hint to StatusLine with loading/on state - P1: consolidate ColumnWidth constant in metrics package - P3: show metricsLoading state in StatusLine --- internal/metrics/sparkline.go | 9 ++++--- internal/view/resource_browser.go | 35 +++++++++++++++++---------- internal/view/resource_browser_nav.go | 17 +++++++++++-- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/internal/metrics/sparkline.go b/internal/metrics/sparkline.go index f74f6b8a..d4002a59 100644 --- a/internal/metrics/sparkline.go +++ b/internal/metrics/sparkline.go @@ -8,7 +8,8 @@ import ( var sparkBlocks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} const ( - sparklineWidth = 7 + SparklineWidth = 7 + ColumnWidth = 13 noDataPlaceholder = "·······" ) @@ -18,8 +19,8 @@ func RenderSparkline(result *MetricResult) string { } values := result.Values - if len(values) > sparklineWidth { - values = values[len(values)-sparklineWidth:] + if len(values) > SparklineWidth { + values = values[len(values)-SparklineWidth:] } minVal, maxVal := values[0], values[0] @@ -49,7 +50,7 @@ func RenderSparkline(result *MetricResult) string { spark += string(sparkBlocks[idx]) } - for len(spark) < sparklineWidth { + for len(spark) < SparklineWidth { spark = "·" + spark } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index ec6b6759..ed4159e8 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -322,9 +322,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.loadMetrics) + } + if len(cmds) > 0 { + return r, tea.Batch(cmds...) } return r, nil @@ -483,13 +490,11 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+r": r.loading = true r.err = nil - cmds := []tea.Cmd{r.loadResources, r.spinner.Tick} if r.metricsEnabled { r.metricsLoading = true r.metricsData = nil - cmds = append(cmds, r.loadMetrics) } - return r, tea.Batch(cmds...) + return r, tea.Batch(r.loadResources, r.spinner.Tick) case "c": r.filterText = "" r.filterInput.SetValue("") @@ -567,6 +572,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": @@ -715,10 +722,11 @@ func (r *ResourceBrowser) buildTable() { cols := r.renderer.Columns() const markColWidth = 2 - const metricsColWidth = 13 + metricsColWidth := metrics.ColumnWidth + effectiveMetricsEnabled := r.metricsEnabled && r.getMetricSpec() != nil numCols := len(cols) + 1 - if r.metricsEnabled { + if effectiveMetricsEnabled { numCols++ } tableCols := make([]table.Column, numCols) @@ -728,7 +736,7 @@ func (r *ResourceBrowser) buildTable() { for _, col := range cols { totalColWidth += col.Width } - if r.metricsEnabled { + if effectiveMetricsEnabled { totalColWidth += metricsColWidth } @@ -740,7 +748,7 @@ func (r *ResourceBrowser) buildTable() { for i, col := range cols { title := col.Name + r.getSortIndicator(i) width := col.Width - if i == len(cols)-1 && !r.metricsEnabled { + if i == len(cols)-1 && !effectiveMetricsEnabled { width += extraWidth } tableCols[i+1] = table.Column{ @@ -749,7 +757,7 @@ func (r *ResourceBrowser) buildTable() { } } - if r.metricsEnabled { + if effectiveMetricsEnabled { spec := r.getMetricSpec() header := "METRICS" if spec != nil { @@ -771,9 +779,8 @@ func (r *ResourceBrowser) buildTable() { fullRow := make(table.Row, numCols) fullRow[0] = markIndicator copy(fullRow[1:], row) - if r.metricsEnabled { - metricResult := r.metricsData.Get(res.GetID()) - fullRow[len(cols)+1] = metrics.RenderSparkline(metricResult) + if effectiveMetricsEnabled { + fullRow[len(cols)+1] = metrics.RenderSparkline(r.metricsData.Get(res.GetID())) } rows[i] = fullRow } @@ -970,6 +977,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 } From 349f8ec43bf3b01fb903e80f1ff1a04f5078a422 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 28 Dec 2025 23:52:43 +0000 Subject: [PATCH 03/12] fix: refresh metrics on auto-reload when enabled --- internal/view/resource_browser.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index ed4159e8..4054d18b 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -372,7 +372,9 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.loadMetrics) + } return r, r.reloadResources case RefreshMsg: From 9b872494e13ee7bca759a43cd97b42ce71dceaf5 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:01:51 +0000 Subject: [PATCH 04/12] fix: address PR review feedback - Add explicit nil check for metricsData in buildTable - Add unit tests for sparkline and MetricData - Add package documentation for metrics - Add context cancellation check in batch loop --- internal/metrics/cloudwatch.go | 5 ++ internal/metrics/sparkline_test.go | 80 ++++++++++++++++++++++++++++++ internal/metrics/types_test.go | 38 ++++++++++++++ internal/view/resource_browser.go | 4 +- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 internal/metrics/sparkline_test.go create mode 100644 internal/metrics/types_test.go diff --git a/internal/metrics/cloudwatch.go b/internal/metrics/cloudwatch.go index 7f332e12..4b64a666 100644 --- a/internal/metrics/cloudwatch.go +++ b/internal/metrics/cloudwatch.go @@ -1,3 +1,4 @@ +// Package metrics provides CloudWatch metrics fetching and sparkline rendering. package metrics import ( @@ -42,6 +43,10 @@ func (f *Fetcher) Fetch(ctx context.Context, resourceIDs []string, spec *render. 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) diff --git a/internal/metrics/sparkline_test.go b/internal/metrics/sparkline_test.go new file mode 100644 index 00000000..b472af9a --- /dev/null +++ b/internal/metrics/sparkline_test.go @@ -0,0 +1,80 @@ +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) + } +} 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/view/resource_browser.go b/internal/view/resource_browser.go index 4054d18b..3661ed72 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -781,8 +781,10 @@ func (r *ResourceBrowser) buildTable() { fullRow := make(table.Row, numCols) fullRow[0] = markIndicator copy(fullRow[1:], row) - if effectiveMetricsEnabled { + if effectiveMetricsEnabled && r.metricsData != nil { fullRow[len(cols)+1] = metrics.RenderSparkline(r.metricsData.Get(res.GetID())) + } else if effectiveMetricsEnabled { + fullRow[len(cols)+1] = metrics.RenderSparkline(nil) } rows[i] = fullRow } From 0c2af40c70713658395e7498c70b5354bee08c2a Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:13:38 +0000 Subject: [PATCH 05/12] fix: address P0 review items - Mark cloudwatch as direct dependency via go mod tidy - Add unit tests for cloudwatch.go (buildQueries, batch splitting, processResults) --- go.mod | 4 +- internal/metrics/cloudwatch_test.go | 112 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 internal/metrics/cloudwatch_test.go 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_test.go b/internal/metrics/cloudwatch_test.go new file mode 100644 index 00000000..fc3f9737 --- /dev/null +++ b/internal/metrics/cloudwatch_test.go @@ -0,0 +1,112 @@ +package metrics + +import ( + "testing" + + "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)) + } +} From 686d124af2a9b23decc247f517829529f02ea606 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:20:10 +0000 Subject: [PATCH 06/12] fix: add 30s timeout to metrics fetch --- internal/view/resource_browser.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 3661ed72..b0d19506 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -24,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 @@ -690,7 +690,10 @@ func (r *ResourceBrowser) loadMetrics() tea.Msg { return metricsLoadedMsg{} } - fetcher, err := metrics.NewFetcher(r.ctx) + ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) + defer cancel() + + fetcher, err := metrics.NewFetcher(ctx) if err != nil { return metricsLoadedMsg{err: err} } @@ -700,7 +703,7 @@ func (r *ResourceBrowser) loadMetrics() tea.Msg { resourceIDs[i] = res.GetID() } - data, err := fetcher.Fetch(r.ctx, resourceIDs, spec) + data, err := fetcher.Fetch(ctx, resourceIDs, spec) return metricsLoadedMsg{data: data, err: err} } From 31933b3038cd81fbf2ed075f788cb6ae5b15cea3 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:41:24 +0000 Subject: [PATCH 07/12] feat: add inline metrics for RDS and Lambda --- custom/lambda/functions/render.go | 16 ++++++++++++++-- custom/rds/instances/render.go | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/custom/lambda/functions/render.go b/custom/lambda/functions/render.go index 7020fb05..5c88a102 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,13 @@ 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)", + } +} diff --git a/custom/rds/instances/render.go b/custom/rds/instances/render.go index 3cf1bb10..63f6c8af 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,13 @@ 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)", + } +} From 37f7c25990f6325c995058f6b28a248ae4c7c37d Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:51:36 +0000 Subject: [PATCH 08/12] fix: address PR review - add Unit field to MetricSpec - Add Unit field to MetricSpec for proper unit display (% for CPU, empty for counts) - Update RenderSparkline to accept unit parameter - Improve error logging with service/resource context - Add test for empty unit (Lambda invocations) Resource ID mapping verified safe: same slice passed to buildQueries/processResults --- custom/ec2/instances/render.go | 1 + custom/lambda/functions/render.go | 1 + custom/rds/instances/render.go | 1 + internal/metrics/sparkline.go | 5 +++-- internal/metrics/sparkline_test.go | 28 +++++++++++++++++++++------- internal/render/render.go | 1 + internal/view/resource_browser.go | 10 +++++++--- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/custom/ec2/instances/render.go b/custom/ec2/instances/render.go index bc8aafb3..453e6297 100644 --- a/custom/ec2/instances/render.go +++ b/custom/ec2/instances/render.go @@ -377,5 +377,6 @@ func (r *InstanceRenderer) MetricSpec() *render.MetricSpec { DimensionName: "InstanceId", Stat: "Average", ColumnHeader: "CPU(15m)", + Unit: "%", } } diff --git a/custom/lambda/functions/render.go b/custom/lambda/functions/render.go index 5c88a102..79b3c053 100644 --- a/custom/lambda/functions/render.go +++ b/custom/lambda/functions/render.go @@ -379,5 +379,6 @@ func (r *FunctionRenderer) MetricSpec() *render.MetricSpec { DimensionName: "FunctionName", Stat: "Sum", ColumnHeader: "INVOC(15m)", + Unit: "", } } diff --git a/custom/rds/instances/render.go b/custom/rds/instances/render.go index 63f6c8af..b8886992 100644 --- a/custom/rds/instances/render.go +++ b/custom/rds/instances/render.go @@ -313,5 +313,6 @@ func (r *InstanceRenderer) MetricSpec() *render.MetricSpec { DimensionName: "DBInstanceIdentifier", Stat: "Average", ColumnHeader: "CPU(15m)", + Unit: "%", } } diff --git a/internal/metrics/sparkline.go b/internal/metrics/sparkline.go index d4002a59..690227cc 100644 --- a/internal/metrics/sparkline.go +++ b/internal/metrics/sparkline.go @@ -13,7 +13,8 @@ const ( noDataPlaceholder = "·······" ) -func RenderSparkline(result *MetricResult) string { +// 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) } @@ -54,5 +55,5 @@ func RenderSparkline(result *MetricResult) string { spark = "·" + spark } - return fmt.Sprintf("%s %3.0f%%", spark, result.Latest) + 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 index b472af9a..74f72cba 100644 --- a/internal/metrics/sparkline_test.go +++ b/internal/metrics/sparkline_test.go @@ -6,21 +6,21 @@ import ( ) func TestRenderSparkline_Nil(t *testing.T) { - result := RenderSparkline(nil) + result := RenderSparkline(nil, "%") if result != "······· -" { t.Errorf("RenderSparkline(nil) = %q, want %q", result, "······· -") } } func TestRenderSparkline_NoData(t *testing.T) { - result := RenderSparkline(&MetricResult{HasData: false}) + 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{}}) + result := RenderSparkline(&MetricResult{HasData: true, Values: []float64{}}, "%") if result != "······· -" { t.Errorf("RenderSparkline(empty) = %q, want %q", result, "······· -") } @@ -31,7 +31,7 @@ func TestRenderSparkline_SingleValue(t *testing.T) { HasData: true, Values: []float64{50.0}, Latest: 50.0, - }) + }, "%") if !strings.HasSuffix(result, " 50%") { t.Errorf("RenderSparkline(single) = %q, want suffix ' 50%%'", result) } @@ -42,7 +42,7 @@ func TestRenderSparkline_MultipleValues(t *testing.T) { 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) } @@ -56,7 +56,7 @@ func TestRenderSparkline_ConstantValues(t *testing.T) { 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) } @@ -71,10 +71,24 @@ func TestRenderSparkline_TruncatesToWidth(t *testing.T) { 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/render/render.go b/internal/render/render.go index 421d8153..98fdb018 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -78,6 +78,7 @@ type MetricSpec struct { DimensionName string Stat string ColumnHeader string + Unit string // Display unit (e.g., "%", "", "ms"). Empty for count-based metrics. } // BaseRenderer provides a default implementation diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index b0d19506..cdc3ba5c 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -364,7 +364,7 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case metricsLoadedMsg: r.metricsLoading = false if msg.err != nil { - log.Warn("failed to load metrics", "error", msg.err) + log.Warn("failed to load metrics", "error", msg.err, "service", r.service, "resource", r.resourceType) } else { r.metricsData = msg.data } @@ -785,9 +785,13 @@ func (r *ResourceBrowser) buildTable() { fullRow[0] = markIndicator copy(fullRow[1:], row) if effectiveMetricsEnabled && r.metricsData != nil { - fullRow[len(cols)+1] = metrics.RenderSparkline(r.metricsData.Get(res.GetID())) + 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) + fullRow[len(cols)+1] = metrics.RenderSparkline(nil, "") } rows[i] = fullRow } From 1224038ac3effe93b3a626fea7a0ed30b7b1c675 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 00:56:58 +0000 Subject: [PATCH 09/12] fix: race condition in loadMetrics by capturing IDs before goroutine --- internal/view/resource_browser.go | 34 +++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index cdc3ba5c..53ccec2f 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -328,7 +328,7 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, r.tickCmd()) } if r.metricsEnabled && r.metricsLoading { - cmds = append(cmds, r.loadMetrics) + cmds = append(cmds, r.loadMetricsCmd()) } if len(cmds) > 0 { return r, tea.Batch(cmds...) @@ -373,7 +373,7 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case autoReloadTickMsg: if r.metricsEnabled && r.getMetricSpec() != nil { - return r, tea.Batch(r.reloadResources, r.loadMetrics) + return r, tea.Batch(r.reloadResources, r.loadMetricsCmd()) } return r, r.reloadResources @@ -529,7 +529,7 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.metricsEnabled = !r.metricsEnabled if r.metricsEnabled && r.metricsData == nil { r.metricsLoading = true - return r, r.loadMetrics + return r, r.loadMetricsCmd() } r.buildTable() } @@ -684,18 +684,12 @@ func (r *ResourceBrowser) loadNextPage() tea.Msg { } } -func (r *ResourceBrowser) loadMetrics() tea.Msg { +// loadMetricsCmd captures resource IDs synchronously before returning the tea.Cmd, +// avoiding a race condition where r.resources could be modified while the goroutine iterates. +func (r *ResourceBrowser) loadMetricsCmd() tea.Cmd { spec := r.getMetricSpec() if spec == nil { - return metricsLoadedMsg{} - } - - ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) - defer cancel() - - fetcher, err := metrics.NewFetcher(ctx) - if err != nil { - return metricsLoadedMsg{err: err} + return nil } resourceIDs := make([]string, len(r.resources)) @@ -703,8 +697,18 @@ func (r *ResourceBrowser) loadMetrics() tea.Msg { resourceIDs[i] = res.GetID() } - data, err := fetcher.Fetch(ctx, resourceIDs, spec) - return metricsLoadedMsg{data: data, err: err} + return func() tea.Msg { + ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) + defer cancel() + + fetcher, err := metrics.NewFetcher(ctx) + if err != nil { + return metricsLoadedMsg{err: err} + } + + data, err := fetcher.Fetch(ctx, resourceIDs, spec) + return metricsLoadedMsg{data: data, err: err} + } } func (r *ResourceBrowser) getMetricSpec() *render.MetricSpec { From a787583cc73e4458505ed24ef9f5d3c00f582537 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 01:04:24 +0000 Subject: [PATCH 10/12] docs: add processResults tests and IAM permissions doc --- README.md | 5 ++- docs/iam-permissions.md | 69 +++++++++++++++++++++++++++++ internal/metrics/cloudwatch_test.go | 60 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 docs/iam-permissions.md 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/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/internal/metrics/cloudwatch_test.go b/internal/metrics/cloudwatch_test.go index fc3f9737..2195e58f 100644 --- a/internal/metrics/cloudwatch_test.go +++ b/internal/metrics/cloudwatch_test.go @@ -3,6 +3,8 @@ 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" ) @@ -110,3 +112,61 @@ func TestProcessResults(t *testing.T) { 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)) + } +} From 28fb33f9acb2d38cfa4f82b38a7371d5ec57b438 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 01:13:16 +0000 Subject: [PATCH 11/12] fix: discard stale metrics when user switches tabs --- internal/view/resource_browser.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 53ccec2f..d747fae2 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -306,8 +306,9 @@ type resourcesErrorMsg struct { } type metricsLoadedMsg struct { - data *metrics.MetricData - err error + data *metrics.MetricData + err error + resourceType string } // Update implements tea.Model @@ -363,6 +364,9 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -684,8 +688,8 @@ func (r *ResourceBrowser) loadNextPage() tea.Msg { } } -// loadMetricsCmd captures resource IDs synchronously before returning the tea.Cmd, -// avoiding a race condition where r.resources could be modified while the goroutine iterates. +// 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 { @@ -696,6 +700,7 @@ func (r *ResourceBrowser) loadMetricsCmd() tea.Cmd { for i, res := range r.resources { resourceIDs[i] = res.GetID() } + resourceType := r.resourceType return func() tea.Msg { ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) @@ -703,11 +708,11 @@ func (r *ResourceBrowser) loadMetricsCmd() tea.Cmd { fetcher, err := metrics.NewFetcher(ctx) if err != nil { - return metricsLoadedMsg{err: err} + return metricsLoadedMsg{err: err, resourceType: resourceType} } data, err := fetcher.Fetch(ctx, resourceIDs, spec) - return metricsLoadedMsg{data: data, err: err} + return metricsLoadedMsg{data: data, err: err, resourceType: resourceType} } } From 2343503bd1707730ce8f32014cac1950df371b0f Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 29 Dec 2025 01:18:06 +0000 Subject: [PATCH 12/12] fix: skip metrics load if context already cancelled --- internal/view/resource_browser.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index d747fae2..7f0c4fed 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -703,6 +703,10 @@ func (r *ResourceBrowser) loadMetricsCmd() tea.Cmd { resourceType := r.resourceType return func() tea.Msg { + if r.ctx.Err() != nil { + return nil + } + ctx, cancel := context.WithTimeout(r.ctx, metricsLoadTimeout) defer cancel()