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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ contexts:
| EC2 | Security Group Browser (list/filter SGs, view rules, add/delete rules with confirmation) | ✅ Implemented |
| VPC | VPC Browser (VPCs → Subnets → Available IPs with reserved-IP exclusion) | ✅ Implemented |
| RDS | RDS Browser (list, start/stop, failover, Aurora cluster support, auto-polling) | ✅ Implemented |
| Route53 | DNS Browser (Hosted Zones → Records → Record Detail, public/private zones) | ✅ Implemented |
| Route53 | DNS Browser (Hosted Zones → Records → Record Detail, create/edit/delete A/CNAME, change status polling) | ✅ Implemented |
| CloudWatch Logs | Log Browser (Log Groups → Streams → Events, live tail with 2s poll, time range presets, filter patterns, log level highlighting) | ✅ Implemented |
| Secrets Manager | Secrets Browser (list secrets, view key-value pairs or raw values) | ✅ Implemented |
| IAM | Access Key Browser (list keys with status, age, last used) | ✅ Implemented |
| IAM | Access Key Rotation (create → verify/apply → deactivate → delete) | ✅ Implemented |
Expand Down Expand Up @@ -180,6 +181,18 @@ contexts:
| `d` | Deactivate old key | Rotation result |
| `x` | Delete old inactive key | Rotation result |

### CloudWatch Logs

| Key | Action | Screen |
|-----|--------|--------|
| `/` | Filter log groups/streams | List |
| `Enter` | Drill into streams/view logs | List |
| `1`-`6` | Time range preset (5m/15m/1h/6h/24h/7d) | Viewer |
| `t` | Toggle live tail (2s poll) | Viewer |
| `f` | Enter filter pattern | Viewer |
| `n` | Load more (older events) | Viewer |
| `PgUp`/`PgDn` | Page scroll | Viewer |

### Context Switcher

| Key | Action |
Expand All @@ -191,7 +204,7 @@ contexts:

### Filtering

Available on: EC2 instances, VPC/Subnets, RDS instances, Route53 zones/records, Secrets Manager, Context Switcher. Press `/` to enter filter mode, type to search, `Esc` or `Enter` to exit filter mode.
Available on: EC2 instances, VPC/Subnets, RDS instances, Route53 zones/records, CloudWatch Log Groups/Streams, Secrets Manager, Context Switcher. Press `/` to enter filter mode, type to search, `Esc` or `Enter` to exit filter mode.

### IAM Access Key Rotation

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ require (
)

require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 // indirect
github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1h
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
Expand All @@ -18,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgq
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 h1:+/lmB/+i2oqkzbmlQxsW0kr/+wmJgmyiEF9VDJicX34=
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0/go.mod h1:PobeppEnIjw4pcgjFryNDZCTH7AiqZw0yb5r98Gvf9c=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA=
github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU=
Expand Down
41 changes: 41 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const (
screenIAMKeyDetail
screenIAMKeyRotateConfirm
screenIAMKeyRotateResult
screenCWLogGroupList
screenCWLogStreamList
screenCWLogViewer
screenContextPicker
screenContextAdd
screenLoading
Expand Down Expand Up @@ -162,6 +165,28 @@ type Model struct {
sgAddInput string // current field text input
sgAddSelectIdx int // index for select-type fields (direction, protocol)

// CloudWatch Logs browser state
cwLogGroups []awsservice.LogGroup
filteredCWLogGroups []awsservice.LogGroup
cwLogGroupIdx int
cwLogGroupFilter string
cwLogGroupFilterActive bool
selectedCWLogGroup *awsservice.LogGroup
cwLogStreams []awsservice.LogStream
filteredCWLogStreams []awsservice.LogStream
cwLogStreamIdx int
cwLogStreamFilter string
cwLogStreamFilterActive bool
selectedCWLogStream *awsservice.LogStream
cwLogEvents []awsservice.LogEvent
cwLogScrollOffset int
cwLogNextToken *string
cwLogTimeRange int // index into preset time ranges
cwLogFilterPattern string
cwLogFilterActive bool // filter pattern input active
cwLogTailing bool // live tail active
cwLogTailToken *string

// Context picker
configPath string
ctxList []config.ContextInfo
Expand Down Expand Up @@ -273,6 +298,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.handleSecurityGroupMsg,
m.handleIAMMsg,
m.handleSecretMsg,
m.handleCloudWatchLogsMsg,
m.handleContextMsg,
} {
if newM, cmd, handled := h(msg); handled {
Expand Down Expand Up @@ -335,6 +361,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSecretList(msg)
case screenSecretDetail:
return m.updateSecretDetail(msg)
case screenCWLogGroupList:
return m.updateCWLogGroupList(msg)
case screenCWLogStreamList:
return m.updateCWLogStreamList(msg)
case screenCWLogViewer:
return m.updateCWLogViewer(msg)
case screenSecurityGroupList:
return m.updateSecurityGroupList(msg)
case screenSecurityGroupDetail:
Expand Down Expand Up @@ -437,6 +469,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case domain.FeatureSecretsBrowser:
m.screen = screenLoading
return m, m.loadSecrets()
case domain.FeatureCloudWatchLogsBrowser:
m.screen = screenLoading
return m, m.loadCWLogGroups()
case domain.FeatureSecurityGroupBrowser:
m.screen = screenLoading
return m, m.loadSecurityGroups()
Expand Down Expand Up @@ -507,6 +542,12 @@ func (m Model) View() string {
v = m.viewSecretList()
case screenSecretDetail:
v = m.viewSecretDetail()
case screenCWLogGroupList:
v = m.viewCWLogGroupList()
case screenCWLogStreamList:
v = m.viewCWLogStreamList()
case screenCWLogViewer:
v = m.viewCWLogViewer()
case screenSecurityGroupList:
v = m.viewSecurityGroupList()
case screenSecurityGroupDetail:
Expand Down
192 changes: 192 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"strings"
"testing"
"time"

tea "github.com/charmbracelet/bubbletea"

Expand Down Expand Up @@ -906,3 +907,194 @@ func TestRotateAccessKeyFeatureUsesCurrentIdentityFlow(t *testing.T) {
t.Fatal("expected load IAM keys command")
}
}

func TestCWLogViewerDownDoesNotOverflowShortEventList(t *testing.T) {
m := New(testConfig(), "", "dev")
m.screen = screenCWLogViewer
m.height = 20
m.cwLogEvents = []awsservice.LogEvent{
{Timestamp: time.Unix(0, 0), Message: "one"},
{Timestamp: time.Unix(1, 0), Message: "two"},
{Timestamp: time.Unix(2, 0), Message: "three"},
}
m.cwLogScrollOffset = 0

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
model := updated.(Model)
if model.cwLogScrollOffset != 0 {
t.Fatalf("expected scroll offset to remain 0, got %d", model.cwLogScrollOffset)
}
}

func TestCWLogTailAppendClampsScrollOffsetForShortEventList(t *testing.T) {
m := New(testConfig(), "", "dev")
m.height = 20
m.screen = screenCWLogViewer
m.cwLogTailing = true
m.cwLogScrollOffset = 7
m.cwLogEvents = []awsservice.LogEvent{
{Timestamp: time.Unix(0, 0), Message: "one"},
{Timestamp: time.Unix(1, 0), Message: "two"},
}

updated, _, handled := m.handleCloudWatchLogsMsg(cwLogEventsLoadedMsg{
append: true,
events: []awsservice.LogEvent{
{Timestamp: time.Unix(2, 0), Message: "three"},
},
})
if !handled {
t.Fatal("expected CloudWatch logs message to be handled")
}

model := updated.(Model)
if model.cwLogScrollOffset != 0 {
t.Fatalf("expected clamped scroll offset 0, got %d", model.cwLogScrollOffset)
}
}

func TestCWLogTailTickSchedulesPollAndNextTick(t *testing.T) {
m := New(testConfig(), "", "dev")
m.cwLogTailing = true
m.selectedCWLogGroup = &awsservice.LogGroup{Name: "/aws/lambda/test"}

updated, cmd, handled := m.handleCloudWatchLogsMsg(cwLogTailTickMsg{})
if !handled {
t.Fatal("expected CloudWatch logs tail tick to be handled")
}
if cmd == nil {
t.Fatal("expected batched tail commands")
}

model := updated.(Model)
if !model.cwLogTailing {
t.Fatal("expected tailing to remain enabled")
}

msg := cmd()
batch, ok := msg.(tea.BatchMsg)
if !ok {
t.Fatalf("expected tea.BatchMsg, got %T", msg)
}
if len(batch) != 2 {
t.Fatalf("expected 2 batched commands, got %d", len(batch))
}
}

func TestCWLogTailAppendDeduplicatesExistingEventIDs(t *testing.T) {
m := New(testConfig(), "", "dev")
m.height = 20
m.screen = screenCWLogViewer
m.cwLogTailing = true
m.cwLogEvents = []awsservice.LogEvent{
{EventID: "evt-1", Timestamp: time.Unix(0, 0), Message: "one"},
{EventID: "evt-2", Timestamp: time.Unix(1, 0), Message: "two"},
}

updated, _, handled := m.handleCloudWatchLogsMsg(cwLogEventsLoadedMsg{
append: true,
events: []awsservice.LogEvent{
{EventID: "evt-2", Timestamp: time.Unix(1, 0), Message: "two"},
{EventID: "evt-3", Timestamp: time.Unix(2, 0), Message: "three"},
},
})
if !handled {
t.Fatal("expected CloudWatch logs message to be handled")
}

model := updated.(Model)
if len(model.cwLogEvents) != 3 {
t.Fatalf("expected 3 deduplicated events, got %d", len(model.cwLogEvents))
}
if model.cwLogEvents[2].EventID != "evt-3" {
t.Fatalf("expected final event to be evt-3, got %q", model.cwLogEvents[2].EventID)
}
}

func TestCWLogTailAppendDeduplicatesEventsWithoutEventIDs(t *testing.T) {
m := New(testConfig(), "", "dev")
m.height = 20
m.screen = screenCWLogViewer
m.cwLogTailing = true
m.cwLogEvents = []awsservice.LogEvent{
{Timestamp: time.Unix(1, 0), Message: "duplicate"},
}

updated, _, handled := m.handleCloudWatchLogsMsg(cwLogEventsLoadedMsg{
append: true,
events: []awsservice.LogEvent{
{Timestamp: time.Unix(1, 0), Message: "duplicate"},
{Timestamp: time.Unix(2, 0), Message: "new event"},
},
})
if !handled {
t.Fatal("expected CloudWatch logs message to be handled")
}

model := updated.(Model)
if len(model.cwLogEvents) != 2 {
t.Fatalf("expected 2 deduplicated events, got %d", len(model.cwLogEvents))
}
if got := strings.TrimSpace(model.cwLogEvents[1].Message); got != "new event" {
t.Fatalf("expected final event to be new event, got %q", got)
}
}

func TestCWLogLoadMoreDoesNotOverwriteTailToken(t *testing.T) {
m := New(testConfig(), "", "dev")
m.cwLogTailToken = stringPtr("tail-token")
m.cwLogNextToken = stringPtr("page-token")

updated, _, handled := m.handleCloudWatchLogsMsg(cwLogEventsLoadedMsg{
append: true,
nextToken: stringPtr("older-page-token"),
updatePaginationToken: true,
events: []awsservice.LogEvent{{EventID: "evt-1", Timestamp: time.Unix(0, 0), Message: "one"}},
})
if !handled {
t.Fatal("expected CloudWatch logs message to be handled")
}

model := updated.(Model)
if got := derefString(model.cwLogTailToken); got != "tail-token" {
t.Fatalf("expected tail token to remain unchanged, got %q", got)
}
if got := derefString(model.cwLogNextToken); got != "older-page-token" {
t.Fatalf("expected pagination token to update, got %q", got)
}
}

func TestCWLogTailAppendDoesNotOverwritePaginationToken(t *testing.T) {
m := New(testConfig(), "", "dev")
m.cwLogNextToken = stringPtr("page-token")
m.cwLogTailToken = stringPtr("tail-token")

updated, _, handled := m.handleCloudWatchLogsMsg(cwLogEventsLoadedMsg{
append: true,
nextToken: stringPtr("new-tail-token"),
updateTailToken: true,
events: []awsservice.LogEvent{{EventID: "evt-2", Timestamp: time.Unix(1, 0), Message: "two"}},
})
if !handled {
t.Fatal("expected CloudWatch logs message to be handled")
}

model := updated.(Model)
if got := derefString(model.cwLogNextToken); got != "page-token" {
t.Fatalf("expected pagination token to remain unchanged, got %q", got)
}
if got := derefString(model.cwLogTailToken); got != "new-tail-token" {
t.Fatalf("expected tail token to update, got %q", got)
}
}

func stringPtr(s string) *string {
return &s
}

func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
18 changes: 18 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ type route53ChangeStatusMsg struct {

type route53PollTickMsg struct{}

type cwLogGroupsLoadedMsg struct {
groups []awsservice.LogGroup
}

type cwLogStreamsLoadedMsg struct {
streams []awsservice.LogStream
}

type cwLogEventsLoadedMsg struct {
events []awsservice.LogEvent
nextToken *string
append bool // true = append (tail/load-more), false = replace
updatePaginationToken bool
updateTailToken bool
}

type cwLogTailTickMsg struct{}

type secretsLoadedMsg struct {
secrets []awsservice.Secret
}
Expand Down
Loading
Loading