diff --git a/README.md b/README.md index f138c70..3d3eced 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 | @@ -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 diff --git a/go.mod b/go.mod index 10cbfab..b541260 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7b43d3c..4e192b1 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index ba04baa..c731eb3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -43,6 +43,9 @@ const ( screenIAMKeyDetail screenIAMKeyRotateConfirm screenIAMKeyRotateResult + screenCWLogGroupList + screenCWLogStreamList + screenCWLogViewer screenContextPicker screenContextAdd screenLoading @@ -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 @@ -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 { @@ -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: @@ -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() @@ -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: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b8b72da..e8c02ae 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -3,6 +3,7 @@ package app import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -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 +} diff --git a/internal/app/messages.go b/internal/app/messages.go index 524532e..700c595 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -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 } diff --git a/internal/app/screen_cloudwatchlogs.go b/internal/app/screen_cloudwatchlogs.go new file mode 100644 index 0000000..9f03e69 --- /dev/null +++ b/internal/app/screen_cloudwatchlogs.go @@ -0,0 +1,658 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + awsservice "unic/internal/services/aws" +) + +// Time range presets: label and duration. +var cwTimeRanges = []struct { + label string + duration time.Duration +}{ + {"5m", 5 * time.Minute}, + {"15m", 15 * time.Minute}, + {"1h", 1 * time.Hour}, + {"6h", 6 * time.Hour}, + {"24h", 24 * time.Hour}, + {"7d", 7 * 24 * time.Hour}, +} + +// Log level styles. +var ( + logErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red + logWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow + logInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green + logDebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dim gray +) + +// --- Message handler --- + +func clampCWLogScrollOffset(offset, totalEvents, visibleLines int) int { + maxOffset := totalEvents - visibleLines + if maxOffset < 0 { + maxOffset = 0 + } + if offset < 0 { + return 0 + } + if offset > maxOffset { + return maxOffset + } + return offset +} + +func cwLogEventDedupKey(evt awsservice.LogEvent) string { + if evt.EventID != "" { + return "id:" + evt.EventID + } + return fmt.Sprintf("fallback:%d:%s", evt.Timestamp.UnixMilli(), strings.TrimSpace(evt.Message)) +} + +func appendUniqueCWLogEvents(existing, incoming []awsservice.LogEvent) []awsservice.LogEvent { + if len(incoming) == 0 { + return existing + } + + seenEventIDs := make(map[string]struct{}, len(existing)) + for _, evt := range existing { + seenEventIDs[cwLogEventDedupKey(evt)] = struct{}{} + } + + result := existing + for _, evt := range incoming { + key := cwLogEventDedupKey(evt) + if _, exists := seenEventIDs[key]; exists { + continue + } + seenEventIDs[key] = struct{}{} + result = append(result, evt) + } + + return result +} + +func (m Model) handleCloudWatchLogsMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case cwLogGroupsLoadedMsg: + m.cwLogGroups = msg.groups + m.filteredCWLogGroups = msg.groups + m.cwLogGroupIdx = 0 + m.screen = screenCWLogGroupList + return m, nil, true + + case cwLogStreamsLoadedMsg: + m.cwLogStreams = msg.streams + m.filteredCWLogStreams = msg.streams + m.cwLogStreamIdx = 0 + m.screen = screenCWLogStreamList + return m, nil, true + + case cwLogEventsLoadedMsg: + if msg.append { + m.cwLogEvents = appendUniqueCWLogEvents(m.cwLogEvents, msg.events) + // Auto-scroll to bottom when tailing + if m.cwLogTailing { + total := len(m.cwLogEvents) + visibleLines := max(m.height-8, 5) + m.cwLogScrollOffset = clampCWLogScrollOffset(total-visibleLines, total, visibleLines) + } + } else { + m.cwLogEvents = msg.events + visibleLines := max(m.height-8, 5) + m.cwLogScrollOffset = clampCWLogScrollOffset(m.cwLogScrollOffset, len(m.cwLogEvents), visibleLines) + } + if msg.updatePaginationToken { + m.cwLogNextToken = msg.nextToken + } + if msg.updateTailToken { + m.cwLogTailToken = msg.nextToken + } + m.screen = screenCWLogViewer + return m, nil, true + + case cwLogTailTickMsg: + if m.cwLogTailing && m.selectedCWLogGroup != nil { + return m, tea.Batch(m.pollCWLogTail(), m.tickCWLogTail()), true + } + return m, nil, true + } + return m, nil, false +} + +// --- Log Group List --- + +func (m Model) updateCWLogGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.cwLogGroupFilterActive { + newFilter, deactivate, changed := handleFilterKey(key, m.cwLogGroupFilter) + m.cwLogGroupFilter = newFilter + if deactivate { + m.cwLogGroupFilterActive = false + } + if changed { + m.filteredCWLogGroups = applyFilter(m.cwLogGroups, m.cwLogGroupFilter) + m.cwLogGroupIdx = 0 + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.cwLogGroupFilter = "" + m.filteredCWLogGroups = m.cwLogGroups + m.cwLogGroupIdx = 0 + case "up", "k": + if m.cwLogGroupIdx > 0 { + m.cwLogGroupIdx-- + } + case "down", "j": + if m.cwLogGroupIdx < len(m.filteredCWLogGroups)-1 { + m.cwLogGroupIdx++ + } + case "/": + m.cwLogGroupFilterActive = true + case "enter": + if len(m.filteredCWLogGroups) > 0 && m.cwLogGroupIdx < len(m.filteredCWLogGroups) { + selected := m.filteredCWLogGroups[m.cwLogGroupIdx] + m.selectedCWLogGroup = &selected + m.screen = screenLoading + return m, m.loadCWLogStreams(selected.Name) + } + } + return m, nil +} + +func (m Model) viewCWLogGroupList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("CloudWatch Log Groups")) + b.WriteString("\n") + + if m.cwLogGroupFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.cwLogGroupFilter))) + } else if m.cwLogGroupFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.cwLogGroupFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredCWLogGroups) == 0 { + b.WriteString(dimStyle.Render(" No matching log groups")) + b.WriteString("\n") + } else { + maxName := 4 // "NAME" + for _, g := range m.filteredCWLogGroups { + if len(g.Name) > maxName { + maxName = len(g.Name) + } + } + if maxName > 60 { + maxName = 60 + } + nameCol := lipgloss.NewStyle().Width(maxName + 2) + retCol := lipgloss.NewStyle().Width(14) + + b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + retCol.Render("RETENTION") + "SIZE")) + b.WriteString("\n") + + visibleLines := max(m.height-9, 5) + start := 0 + if m.cwLogGroupIdx >= visibleLines { + start = m.cwLogGroupIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredCWLogGroups)) + + for i := start; i < end; i++ { + g := m.filteredCWLogGroups[i] + cursor := " " + style := normalStyle + if i == m.cwLogGroupIdx { + cursor = "> " + style = selectedStyle + } + name := g.Name + if len(name) > maxName { + name = name[:maxName-3] + "..." + } + retention := "Never" + if g.RetentionDays > 0 { + retention = fmt.Sprintf("%d days", g.RetentionDays) + } + row := cursor + + nameCol.Inherit(style).Render(name) + + retCol.Inherit(dimStyle).Render(retention) + + dimStyle.Render(awsservice.FormatBytes(g.StoredBytes)) + b.WriteString(row) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d log groups", len(m.filteredCWLogGroups), len(m.cwLogGroups)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: streams • esc: back • H: home")) + return b.String() +} + +// --- Log Stream List --- + +func (m Model) updateCWLogStreamList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.cwLogStreamFilterActive { + newFilter, deactivate, changed := handleFilterKey(key, m.cwLogStreamFilter) + m.cwLogStreamFilter = newFilter + if deactivate { + m.cwLogStreamFilterActive = false + } + if changed { + m.filteredCWLogStreams = applyFilter(m.cwLogStreams, m.cwLogStreamFilter) + m.cwLogStreamIdx = 0 + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenCWLogGroupList + m.cwLogStreamFilter = "" + m.filteredCWLogStreams = m.cwLogStreams + m.cwLogStreamIdx = 0 + case "up", "k": + if m.cwLogStreamIdx > 0 { + m.cwLogStreamIdx-- + } + case "down", "j": + if m.cwLogStreamIdx < len(m.filteredCWLogStreams)-1 { + m.cwLogStreamIdx++ + } + case "/": + m.cwLogStreamFilterActive = true + case "enter": + if len(m.filteredCWLogStreams) > 0 && m.cwLogStreamIdx < len(m.filteredCWLogStreams) { + selected := m.filteredCWLogStreams[m.cwLogStreamIdx] + m.selectedCWLogStream = &selected + m.cwLogTimeRange = 2 // default: 1h + m.cwLogFilterPattern = "" + m.cwLogTailing = false + m.cwLogTailToken = nil + m.screen = screenLoading + return m, m.loadCWLogEvents(false) + } + } + return m, nil +} + +func (m Model) viewCWLogStreamList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + groupName := "" + if m.selectedCWLogGroup != nil { + groupName = m.selectedCWLogGroup.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("Log Streams — %s", groupName))) + b.WriteString("\n") + + if m.cwLogStreamFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.cwLogStreamFilter))) + } else if m.cwLogStreamFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.cwLogStreamFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredCWLogStreams) == 0 { + b.WriteString(dimStyle.Render(" No matching log streams")) + b.WriteString("\n") + } else { + maxName := 4 + for _, s := range m.filteredCWLogStreams { + if len(s.Name) > maxName { + maxName = len(s.Name) + } + } + if maxName > 60 { + maxName = 60 + } + nameCol := lipgloss.NewStyle().Width(maxName + 2) + + b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + "LAST EVENT")) + b.WriteString("\n") + + visibleLines := max(m.height-9, 5) + start := 0 + if m.cwLogStreamIdx >= visibleLines { + start = m.cwLogStreamIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredCWLogStreams)) + + for i := start; i < end; i++ { + s := m.filteredCWLogStreams[i] + cursor := " " + style := normalStyle + if i == m.cwLogStreamIdx { + cursor = "> " + style = selectedStyle + } + name := s.Name + if len(name) > maxName { + name = name[:maxName-3] + "..." + } + lastEvent := "No events" + if !s.LastEventTime.IsZero() { + lastEvent = s.LastEventTime.Local().Format("2006-01-02 15:04:05") + } + row := cursor + + nameCol.Inherit(style).Render(name) + + dimStyle.Render(lastEvent) + b.WriteString(row) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d streams", len(m.filteredCWLogStreams), len(m.cwLogStreams)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: view logs • esc: back • H: home")) + return b.String() +} + +// --- Log Viewer --- + +func (m Model) updateCWLogViewer(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Filter pattern input mode + if m.cwLogFilterActive { + switch key { + case "esc": + m.cwLogFilterActive = false + case "enter": + m.cwLogFilterActive = false + // Reload with new filter + m.screen = screenLoading + return m, m.loadCWLogEvents(false) + case "backspace": + if len(m.cwLogFilterPattern) > 0 { + m.cwLogFilterPattern = m.cwLogFilterPattern[:len(m.cwLogFilterPattern)-1] + } + default: + if len(key) == 1 { + m.cwLogFilterPattern += key + } + } + return m, nil + } + + visibleLines := max(m.height-8, 5) + + switch key { + case "q", "esc": + m.cwLogTailing = false + m.screen = screenCWLogStreamList + case "up", "k": + if m.cwLogScrollOffset > 0 { + m.cwLogScrollOffset-- + } + case "down", "j": + maxOffset := clampCWLogScrollOffset(len(m.cwLogEvents)-visibleLines, len(m.cwLogEvents), visibleLines) + if m.cwLogScrollOffset < maxOffset { + m.cwLogScrollOffset++ + } + case "pgup": + m.cwLogScrollOffset -= visibleLines + if m.cwLogScrollOffset < 0 { + m.cwLogScrollOffset = 0 + } + case "pgdown": + m.cwLogScrollOffset += visibleLines + m.cwLogScrollOffset = clampCWLogScrollOffset(m.cwLogScrollOffset, len(m.cwLogEvents), visibleLines) + case "t": // Toggle live tail + m.cwLogTailing = !m.cwLogTailing + if m.cwLogTailing { + return m, m.tickCWLogTail() + } + case "f": // Filter pattern input + m.cwLogFilterActive = true + case "n": // Load more (older events) + if m.cwLogNextToken != nil { + return m, m.loadCWLogEvents(true) + } + case "1", "2", "3", "4", "5", "6": // Time range presets + idx := int(key[0] - '1') + if idx >= 0 && idx < len(cwTimeRanges) { + m.cwLogTimeRange = idx + m.cwLogTailing = false + m.screen = screenLoading + return m, m.loadCWLogEvents(false) + } + } + return m, nil +} + +func (m Model) viewCWLogViewer() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + + // Header + groupName := "" + if m.selectedCWLogGroup != nil { + groupName = m.selectedCWLogGroup.Name + } + streamName := "All streams" + if m.selectedCWLogStream != nil { + streamName = m.selectedCWLogStream.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("Logs — %s / %s", groupName, streamName))) + b.WriteString("\n") + + // Status line: time range + filter + tail indicator + var status []string + if m.cwLogTimeRange >= 0 && m.cwLogTimeRange < len(cwTimeRanges) { + // Build time range selector + var ranges []string + for i, tr := range cwTimeRanges { + if i == m.cwLogTimeRange { + ranges = append(ranges, selectedStyle.Render(fmt.Sprintf("[%s]", tr.label))) + } else { + ranges = append(ranges, dimStyle.Render(fmt.Sprintf(" %s ", tr.label))) + } + } + status = append(status, strings.Join(ranges, "")) + } + if m.cwLogFilterActive { + status = append(status, filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.cwLogFilterPattern))) + } else if m.cwLogFilterPattern != "" { + status = append(status, dimStyle.Render(fmt.Sprintf("Filter: %s", m.cwLogFilterPattern))) + } + if m.cwLogTailing { + status = append(status, filterStyle.Render("TAILING")) + } + if len(status) > 0 { + b.WriteString(strings.Join(status, " ")) + } + b.WriteString("\n\n") + + // Log events + if len(m.cwLogEvents) == 0 { + b.WriteString(dimStyle.Render(" No log events found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := clampCWLogScrollOffset(m.cwLogScrollOffset, len(m.cwLogEvents), visibleLines) + end := min(start+visibleLines, len(m.cwLogEvents)) + + for i := start; i < end; i++ { + evt := m.cwLogEvents[i] + ts := dimStyle.Render(evt.Timestamp.Local().Format("15:04:05.000")) + msg := strings.TrimSpace(evt.Message) + + var levelStr string + switch evt.Level { + case "ERROR", "FATAL": + levelStr = logErrorStyle.Render(fmt.Sprintf("[%s]", evt.Level)) + case "WARN": + levelStr = logWarnStyle.Render("[WARN]") + case "INFO": + levelStr = logInfoStyle.Render("[INFO]") + case "DEBUG": + levelStr = logDebugStyle.Render("[DEBUG]") + } + + if levelStr != "" { + b.WriteString(fmt.Sprintf(" %s %s %s", ts, levelStr, msg)) + } else { + b.WriteString(fmt.Sprintf(" %s %s", ts, msg)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d events (showing %d-%d)", len(m.cwLogEvents), start+1, end))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: scroll • pgup/pgdn: page • 1-6: time range • f: filter • t: tail • n: load more • esc: back")) + return b.String() +} + +// --- Commands --- + +func (m Model) loadCWLogGroups() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + groups, err := repo.ListLogGroups(ctx) + if err != nil { + return errMsg{err: err} + } + if len(groups) == 0 { + return errMsg{err: fmt.Errorf("no log groups found")} + } + return cwLogGroupsLoadedMsg{groups: groups} + } +} + +func (m Model) loadCWLogStreams(logGroupName string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + } + + streams, err := repo.ListLogStreams(ctx, logGroupName) + if err != nil { + return errMsg{err: err} + } + if len(streams) == 0 { + return errMsg{err: fmt.Errorf("no log streams found in %s", logGroupName)} + } + return cwLogStreamsLoadedMsg{streams: streams} + } +} + +func (m Model) loadCWLogEvents(appendMode bool) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return cwLogEventsLoadedMsg{append: appendMode} + } + } + + if m.selectedCWLogGroup == nil { + return cwLogEventsLoadedMsg{append: appendMode} + } + + now := time.Now() + timeIdx := m.cwLogTimeRange + if timeIdx < 0 || timeIdx >= len(cwTimeRanges) { + timeIdx = 2 // default: 1h + } + duration := cwTimeRanges[timeIdx].duration + startTime := now.Add(-duration).UnixMilli() + endTime := now.UnixMilli() + + var token *string + if appendMode { + token = m.cwLogNextToken + } + + events, nextToken, err := repo.FilterLogEvents(ctx, m.selectedCWLogGroup.Name, startTime, endTime, m.cwLogFilterPattern, token) + if err != nil { + return errMsg{err: err} + } + + return cwLogEventsLoadedMsg{ + events: events, + nextToken: nextToken, + append: appendMode, + updatePaginationToken: true, + updateTailToken: !appendMode, + } + } +} + +func (m Model) pollCWLogTail() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return cwLogEventsLoadedMsg{append: true} + } + } + + if m.selectedCWLogGroup == nil { + return cwLogEventsLoadedMsg{append: true} + } + + // Poll from last token or last 30 seconds + now := time.Now() + startTime := now.Add(-30 * time.Second).UnixMilli() + endTime := now.UnixMilli() + + events, nextToken, err := repo.FilterLogEvents(ctx, m.selectedCWLogGroup.Name, startTime, endTime, m.cwLogFilterPattern, m.cwLogTailToken) + if err != nil { + return cwLogEventsLoadedMsg{append: true} + } + + return cwLogEventsLoadedMsg{ + events: events, + nextToken: nextToken, + append: true, + updateTailToken: true, + } + } +} + +func (m Model) tickCWLogTail() tea.Cmd { + return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg { + return cwLogTailTickMsg{} + }) +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index c4a28c4..4ede2cc 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -52,6 +52,15 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceCloudWatchLogs, + Features: []Feature{ + { + Kind: FeatureCloudWatchLogsBrowser, + Description: "Browse log groups, streams, and events", + }, + }, + }, { Name: ServiceIAM, Features: []Feature{ diff --git a/internal/domain/model.go b/internal/domain/model.go index 86ff3b0..bc32e95 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -10,6 +10,7 @@ const ( ServiceRoute53 AwsService = "Route53" ServiceSecretsManager AwsService = "Secrets Manager" ServiceIAM AwsService = "IAM" + ServiceCloudWatchLogs AwsService = "CloudWatch Logs" ) // FeatureKind represents a specific feature within a service. @@ -24,6 +25,7 @@ const ( FeatureSecurityGroupBrowser FeatureKind = "Security Group Browser" FeatureListAccessKeys FeatureKind = "ListAccessKeys" FeatureRotateAccessKey FeatureKind = "RotateAccessKey" + FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/cloudwatch_logs.go b/internal/services/aws/cloudwatch_logs.go new file mode 100644 index 0000000..f0837f7 --- /dev/null +++ b/internal/services/aws/cloudwatch_logs.go @@ -0,0 +1,125 @@ +package aws + +import ( + "context" + "fmt" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + + uniclog "unic/internal/log" +) + +// ListLogGroups returns all CloudWatch Logs log groups in the current account/region. +func (r *AwsRepository) ListLogGroups(ctx context.Context) ([]LogGroup, error) { + uniclog.Debug("aws", "ListLogGroups called") + var groups []LogGroup + var nextToken *string + + for { + output, err := r.CloudWatchLogsClient.DescribeLogGroups(ctx, &cloudwatchlogs.DescribeLogGroupsInput{ + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe log groups: %w", err) + } + + for _, lg := range output.LogGroups { + group := LogGroup{ + Name: awssdk.ToString(lg.LogGroupName), + ARN: awssdk.ToString(lg.Arn), + StoredBytes: awssdk.ToInt64(lg.StoredBytes), + } + if lg.RetentionInDays != nil { + group.RetentionDays = *lg.RetentionInDays + } + if lg.CreationTime != nil { + group.CreationTime = time.UnixMilli(*lg.CreationTime) + } + groups = append(groups, group) + } + + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + + return groups, nil +} + +// ListLogStreams returns log streams for a given log group, sorted by last event time descending. +func (r *AwsRepository) ListLogStreams(ctx context.Context, logGroupName string) ([]LogStream, error) { + uniclog.Debug("aws", "ListLogStreams called", "log_group", logGroupName) + var streams []LogStream + var nextToken *string + + for { + output, err := r.CloudWatchLogsClient.DescribeLogStreams(ctx, &cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: awssdk.String(logGroupName), + OrderBy: "LastEventTime", + Descending: awssdk.Bool(true), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe log streams for %s: %w", logGroupName, err) + } + + for _, ls := range output.LogStreams { + stream := LogStream{ + Name: awssdk.ToString(ls.LogStreamName), + } + if ls.LastEventTimestamp != nil { + stream.LastEventTime = time.UnixMilli(*ls.LastEventTimestamp) + } + if ls.CreationTime != nil { + stream.CreationTime = time.UnixMilli(*ls.CreationTime) + } + streams = append(streams, stream) + } + + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + + return streams, nil +} + +// FilterLogEvents returns log events matching the given criteria. +// Returns events, a next token for pagination, and any error. +func (r *AwsRepository) FilterLogEvents(ctx context.Context, logGroupName string, startTime, endTime int64, filterPattern string, nextToken *string) ([]LogEvent, *string, error) { + uniclog.Debug("aws", "FilterLogEvents called", "log_group", logGroupName, "start", startTime, "end", endTime) + + input := &cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: awssdk.String(logGroupName), + StartTime: awssdk.Int64(startTime), + EndTime: awssdk.Int64(endTime), + NextToken: nextToken, + } + if filterPattern != "" { + input.FilterPattern = awssdk.String(filterPattern) + } + + output, err := r.CloudWatchLogsClient.FilterLogEvents(ctx, input) + if err != nil { + return nil, nil, fmt.Errorf("failed to filter log events for %s: %w", logGroupName, err) + } + + var events []LogEvent + for _, e := range output.Events { + event := LogEvent{ + EventID: awssdk.ToString(e.EventId), + Message: awssdk.ToString(e.Message), + } + if e.Timestamp != nil { + event.Timestamp = time.UnixMilli(*e.Timestamp) + } + event.Level = extractLogLevel(event.Message) + events = append(events, event) + } + + return events, output.NextToken, nil +} diff --git a/internal/services/aws/cloudwatch_logs_model.go b/internal/services/aws/cloudwatch_logs_model.go new file mode 100644 index 0000000..1cd0eed --- /dev/null +++ b/internal/services/aws/cloudwatch_logs_model.go @@ -0,0 +1,98 @@ +package aws + +import ( + "fmt" + "strings" + "time" +) + +// LogGroup holds essential information about a CloudWatch Logs log group. +type LogGroup struct { + Name string + ARN string + RetentionDays int32 // 0 = never expire + StoredBytes int64 + CreationTime time.Time +} + +// DisplayTitle returns a formatted string for list display. +func (g LogGroup) DisplayTitle() string { + retention := "Never expire" + if g.RetentionDays > 0 { + retention = fmt.Sprintf("%d days", g.RetentionDays) + } + return fmt.Sprintf("%s %s %s", g.Name, retention, FormatBytes(g.StoredBytes)) +} + +// FilterText returns a lowercase string for keyword matching. +func (g LogGroup) FilterText() string { + return strings.ToLower(g.Name) +} + +// LogStream holds essential information about a CloudWatch Logs log stream. +type LogStream struct { + Name string + LastEventTime time.Time + CreationTime time.Time +} + +// DisplayTitle returns a formatted string for list display. +func (s LogStream) DisplayTitle() string { + lastEvent := "No events" + if !s.LastEventTime.IsZero() { + lastEvent = s.LastEventTime.Local().Format("2006-01-02 15:04:05") + } + return fmt.Sprintf("%s %s", s.Name, lastEvent) +} + +// FilterText returns a lowercase string for keyword matching. +func (s LogStream) FilterText() string { + return strings.ToLower(s.Name) +} + +// LogEvent holds a single log event from CloudWatch Logs. +type LogEvent struct { + EventID string + Timestamp time.Time + Message string + Level string // extracted: INFO, WARN, ERROR, DEBUG, FATAL, or empty +} + +// DisplayTitle returns a formatted string for list display. +func (e LogEvent) DisplayTitle() string { + ts := e.Timestamp.Local().Format("15:04:05.000") + if e.Level != "" { + return fmt.Sprintf("%s [%s] %s", ts, e.Level, strings.TrimSpace(e.Message)) + } + return fmt.Sprintf("%s %s", ts, strings.TrimSpace(e.Message)) +} + +// FilterText returns a lowercase string for keyword matching. +func (e LogEvent) FilterText() string { + return strings.ToLower(e.Message) +} + +// extractLogLevel attempts to parse a log level from the message. +func extractLogLevel(message string) string { + upper := strings.ToUpper(message) + for _, level := range []string{"FATAL", "ERROR", "WARN", "INFO", "DEBUG"} { + if strings.Contains(upper, level) { + return level + } + } + return "" +} + +// FormatBytes returns a human-readable byte size. +func FormatBytes(b int64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.1f GB", float64(b)/float64(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(b)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} diff --git a/internal/services/aws/cloudwatch_logs_test.go b/internal/services/aws/cloudwatch_logs_test.go new file mode 100644 index 0000000..8f6f323 --- /dev/null +++ b/internal/services/aws/cloudwatch_logs_test.go @@ -0,0 +1,396 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + cwltypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" +) + +// mockCloudWatchLogsClient implements CloudWatchLogsClientAPI for testing. +type mockCloudWatchLogsClient struct { + describeLogGroupsFunc func(ctx context.Context, params *cloudwatchlogs.DescribeLogGroupsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) + describeLogStreamsFunc func(ctx context.Context, params *cloudwatchlogs.DescribeLogStreamsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) + filterLogEventsFunc func(ctx context.Context, params *cloudwatchlogs.FilterLogEventsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) +} + +func (m *mockCloudWatchLogsClient) DescribeLogGroups(ctx context.Context, params *cloudwatchlogs.DescribeLogGroupsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + return m.describeLogGroupsFunc(ctx, params, optFns...) +} + +func (m *mockCloudWatchLogsClient) DescribeLogStreams(ctx context.Context, params *cloudwatchlogs.DescribeLogStreamsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + return m.describeLogStreamsFunc(ctx, params, optFns...) +} + +func (m *mockCloudWatchLogsClient) FilterLogEvents(ctx context.Context, params *cloudwatchlogs.FilterLogEventsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) { + if m.filterLogEventsFunc != nil { + return m.filterLogEventsFunc(ctx, params, optFns...) + } + return nil, fmt.Errorf("not implemented") +} + +// --- ListLogGroups tests --- + +func TestListLogGroups_Success(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogGroupsFunc: func(_ context.Context, _ *cloudwatchlogs.DescribeLogGroupsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + var retention int32 = 30 + var creationTime int64 = 1700000000000 + return &cloudwatchlogs.DescribeLogGroupsOutput{ + LogGroups: []cwltypes.LogGroup{ + { + LogGroupName: awssdk.String("/aws/lambda/my-function"), + Arn: awssdk.String("arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"), + RetentionInDays: &retention, + StoredBytes: awssdk.Int64(1048576), + CreationTime: &creationTime, + }, + { + LogGroupName: awssdk.String("/ecs/my-service"), + Arn: awssdk.String("arn:aws:logs:us-east-1:123456789012:log-group:/ecs/my-service:*"), + StoredBytes: awssdk.Int64(0), + }, + }, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + groups, err := repo.ListLogGroups(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + + g := groups[0] + if g.Name != "/aws/lambda/my-function" { + t.Errorf("expected name '/aws/lambda/my-function', got %q", g.Name) + } + if g.RetentionDays != 30 { + t.Errorf("expected retention 30, got %d", g.RetentionDays) + } + if g.StoredBytes != 1048576 { + t.Errorf("expected stored bytes 1048576, got %d", g.StoredBytes) + } + + g2 := groups[1] + if g2.RetentionDays != 0 { + t.Errorf("expected retention 0 (never expire), got %d", g2.RetentionDays) + } +} + +func TestListLogGroups_Empty(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogGroupsFunc: func(_ context.Context, _ *cloudwatchlogs.DescribeLogGroupsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + return &cloudwatchlogs.DescribeLogGroupsOutput{ + LogGroups: []cwltypes.LogGroup{}, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + groups, err := repo.ListLogGroups(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) != 0 { + t.Errorf("expected empty slice, got %d", len(groups)) + } +} + +func TestListLogGroups_Error(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogGroupsFunc: func(_ context.Context, _ *cloudwatchlogs.DescribeLogGroupsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + return nil, fmt.Errorf("access denied") + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + _, err := repo.ListLogGroups(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- ListLogStreams tests --- + +func TestListLogStreams_Success(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogStreamsFunc: func(_ context.Context, params *cloudwatchlogs.DescribeLogStreamsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + if awssdk.ToString(params.LogGroupName) != "/aws/lambda/my-function" { + t.Errorf("expected log group '/aws/lambda/my-function', got %q", awssdk.ToString(params.LogGroupName)) + } + var lastEvent int64 = 1700000000000 + return &cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []cwltypes.LogStream{ + { + LogStreamName: awssdk.String("2024/01/01/[$LATEST]abc123"), + LastEventTimestamp: &lastEvent, + }, + { + LogStreamName: awssdk.String("2024/01/01/[$LATEST]def456"), + }, + }, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + streams, err := repo.ListLogStreams(context.Background(), "/aws/lambda/my-function") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(streams) != 2 { + t.Fatalf("expected 2 streams, got %d", len(streams)) + } + + s := streams[0] + if s.Name != "2024/01/01/[$LATEST]abc123" { + t.Errorf("expected name '2024/01/01/[$LATEST]abc123', got %q", s.Name) + } + if s.LastEventTime.IsZero() { + t.Error("expected non-zero last event time") + } +} + +func TestListLogStreams_Empty(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogStreamsFunc: func(_ context.Context, _ *cloudwatchlogs.DescribeLogStreamsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + return &cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []cwltypes.LogStream{}, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + streams, err := repo.ListLogStreams(context.Background(), "/test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(streams) != 0 { + t.Errorf("expected empty slice, got %d", len(streams)) + } +} + +func TestListLogStreams_Error(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + describeLogStreamsFunc: func(_ context.Context, _ *cloudwatchlogs.DescribeLogStreamsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + return nil, fmt.Errorf("log group not found") + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + _, err := repo.ListLogStreams(context.Background(), "/nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- FilterLogEvents tests --- + +func TestFilterLogEvents_Success(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + filterLogEventsFunc: func(_ context.Context, params *cloudwatchlogs.FilterLogEventsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) { + if awssdk.ToString(params.LogGroupName) != "/aws/lambda/my-function" { + t.Errorf("expected log group name, got %q", awssdk.ToString(params.LogGroupName)) + } + var ts int64 = 1700000000000 + return &cloudwatchlogs.FilterLogEventsOutput{ + Events: []cwltypes.FilteredLogEvent{ + { + EventId: awssdk.String("evt-1"), + Timestamp: &ts, + Message: awssdk.String("2024-01-01T00:00:00Z INFO Starting handler"), + }, + { + EventId: awssdk.String("evt-2"), + Timestamp: &ts, + Message: awssdk.String("2024-01-01T00:00:01Z ERROR Something failed"), + }, + }, + NextToken: awssdk.String("next-page-token"), + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + events, nextToken, err := repo.FilterLogEvents(context.Background(), "/aws/lambda/my-function", 1700000000000, 1700000060000, "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Level != "INFO" { + t.Errorf("expected level 'INFO', got %q", events[0].Level) + } + if events[0].EventID != "evt-1" { + t.Errorf("expected event ID 'evt-1', got %q", events[0].EventID) + } + if events[1].Level != "ERROR" { + t.Errorf("expected level 'ERROR', got %q", events[1].Level) + } + if events[1].EventID != "evt-2" { + t.Errorf("expected event ID 'evt-2', got %q", events[1].EventID) + } + if nextToken == nil { + t.Error("expected non-nil next token") + } +} + +func TestFilterLogEvents_Empty(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + filterLogEventsFunc: func(_ context.Context, _ *cloudwatchlogs.FilterLogEventsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) { + return &cloudwatchlogs.FilterLogEventsOutput{ + Events: []cwltypes.FilteredLogEvent{}, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + events, _, err := repo.FilterLogEvents(context.Background(), "/test", 0, 0, "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 0 { + t.Errorf("expected empty slice, got %d", len(events)) + } +} + +func TestFilterLogEvents_Error(t *testing.T) { + mock := &mockCloudWatchLogsClient{ + filterLogEventsFunc: func(_ context.Context, _ *cloudwatchlogs.FilterLogEventsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) { + return nil, fmt.Errorf("throttled") + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + _, _, err := repo.FilterLogEvents(context.Background(), "/test", 0, 0, "", nil) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestFilterLogEvents_WithToken(t *testing.T) { + token := "page-2-token" + mock := &mockCloudWatchLogsClient{ + filterLogEventsFunc: func(_ context.Context, params *cloudwatchlogs.FilterLogEventsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) { + if awssdk.ToString(params.NextToken) != token { + t.Errorf("expected token %q, got %q", token, awssdk.ToString(params.NextToken)) + } + return &cloudwatchlogs.FilterLogEventsOutput{ + Events: []cwltypes.FilteredLogEvent{}, + }, nil + }, + } + + repo := &AwsRepository{CloudWatchLogsClient: mock} + _, _, err := repo.FilterLogEvents(context.Background(), "/test", 0, 0, "", &token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- Model tests --- + +func TestLogGroupDisplayTitle(t *testing.T) { + g := LogGroup{Name: "/aws/lambda/my-func", RetentionDays: 30, StoredBytes: 1048576} + title := g.DisplayTitle() + if !strings.Contains(title, "/aws/lambda/my-func") { + t.Errorf("DisplayTitle should contain name, got %q", title) + } + if !strings.Contains(title, "30 days") { + t.Errorf("DisplayTitle should contain retention, got %q", title) + } + if !strings.Contains(title, "1.0 MB") { + t.Errorf("DisplayTitle should contain size, got %q", title) + } +} + +func TestLogGroupDisplayTitle_NeverExpire(t *testing.T) { + g := LogGroup{Name: "/test", RetentionDays: 0, StoredBytes: 0} + title := g.DisplayTitle() + if !strings.Contains(title, "Never expire") { + t.Errorf("DisplayTitle should say 'Never expire' for 0 retention, got %q", title) + } +} + +func TestLogGroupFilterText(t *testing.T) { + g := LogGroup{Name: "/AWS/Lambda/MyFunc"} + ft := g.FilterText() + if !strings.Contains(ft, "/aws/lambda/myfunc") { + t.Errorf("FilterText should be lowercase, got %q", ft) + } +} + +func TestLogStreamDisplayTitle(t *testing.T) { + s := LogStream{Name: "stream-1"} + title := s.DisplayTitle() + if !strings.Contains(title, "stream-1") { + t.Errorf("DisplayTitle should contain name, got %q", title) + } + if !strings.Contains(title, "No events") { + t.Errorf("DisplayTitle should say 'No events' for zero time, got %q", title) + } +} + +func TestLogStreamFilterText(t *testing.T) { + s := LogStream{Name: "MyStream"} + ft := s.FilterText() + if ft != "mystream" { + t.Errorf("FilterText should be lowercase, got %q", ft) + } +} + +func TestLogEventDisplayTitle(t *testing.T) { + e := LogEvent{Message: "INFO something happened", Level: "INFO"} + title := e.DisplayTitle() + if !strings.Contains(title, "[INFO]") { + t.Errorf("DisplayTitle should contain level, got %q", title) + } +} + +func TestExtractLogLevel(t *testing.T) { + tests := []struct { + message string + want string + }{ + {"2024-01-01 INFO Starting", "INFO"}, + {"ERROR: something broke", "ERROR"}, + {"[WARN] low memory", "WARN"}, + {"DEBUG trace message", "DEBUG"}, + {"FATAL crash", "FATAL"}, + {"just a plain message", ""}, + } + for _, tt := range tests { + got := extractLogLevel(tt.message) + if got != tt.want { + t.Errorf("extractLogLevel(%q) = %q, want %q", tt.message, got, tt.want) + } + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + } + for _, tt := range tests { + got := FormatBytes(tt.input) + if got != tt.want { + t.Errorf("FormatBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index d9e6eba..6957a53 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" "github.com/aws/aws-sdk-go-v2/service/sts" "unic/internal/config" @@ -41,6 +42,9 @@ var _ IAMClientAPI = (*iam.Client)(nil) // Verify *sts.Client satisfies STSClientAPI at compile time. var _ STSClientAPI = (*sts.Client)(nil) +// Verify *cloudwatchlogs.Client satisfies CloudWatchLogsClientAPI at compile time. +var _ CloudWatchLogsClientAPI = (*cloudwatchlogs.Client)(nil) + // SSMClientAPI is the interface for SSM operations used by AwsRepository. type SSMClientAPI interface { ssm.DescribeInstanceInformationAPIClient @@ -86,6 +90,13 @@ type STSClientAPI interface { GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } +// CloudWatchLogsClientAPI is the interface for CloudWatch Logs operations used by AwsRepository. +type CloudWatchLogsClientAPI interface { + DescribeLogGroups(ctx context.Context, params *cloudwatchlogs.DescribeLogGroupsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogGroupsOutput, error) + DescribeLogStreams(ctx context.Context, params *cloudwatchlogs.DescribeLogStreamsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.DescribeLogStreamsOutput, error) + FilterLogEvents(ctx context.Context, params *cloudwatchlogs.FilterLogEventsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) +} + // EC2ClientAPI is the interface for EC2 operations used by AwsRepository. type EC2ClientAPI interface { ec2.DescribeInstancesAPIClient @@ -115,6 +126,7 @@ type AwsRepository struct { SecretsManagerClient SecretsManagerClientAPI IAMClient IAMClientAPI STSClient STSClientAPI + CloudWatchLogsClient CloudWatchLogsClientAPI Region string Profile string } @@ -185,6 +197,7 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, SecretsManagerClient: secretsmanager.NewFromConfig(awsCfg), IAMClient: iam.NewFromConfig(awsCfg), STSClient: sts.NewFromConfig(awsCfg), + CloudWatchLogsClient: cloudwatchlogs.NewFromConfig(awsCfg), Region: cfg.Region, Profile: cfg.Profile, }, nil