Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 15 additions & 2 deletions custom/ec2/instances/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "%",
}
}
17 changes: 15 additions & 2 deletions custom/lambda/functions/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: "",
}
}
17 changes: 15 additions & 2 deletions custom/rds/instances/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "%",
}
}
69 changes: 69 additions & 0 deletions docs/iam-permissions.md
Original file line number Diff line number Diff line change
@@ -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 | `<service>: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.
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions internal/metrics/cloudwatch.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading