diff --git a/.gitignore b/.gitignore index 83f638d1..6c92ad6f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ claws.log # Node.js node_modules/ .claude +.gtrconfig diff --git a/README.md b/README.md index 31214eb8..fcebb408 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,7 @@ timeouts: multi_region_fetch: 60s # Multi-region parallel fetch timeout (default: 30s) tag_search: 45s # Tag search timeout (default: 30s) metrics_load: 30s # CloudWatch metrics load timeout (default: 30s) + log_fetch: 15s # CloudWatch Logs fetch timeout (default: 10s) concurrency: max_fetches: 100 # Max concurrent API fetches (default: 50) diff --git a/custom/cloudwatch/log-groups/actions.go b/custom/cloudwatch/log-groups/actions.go index c2b0d4db..eb02b42f 100644 --- a/custom/cloudwatch/log-groups/actions.go +++ b/custom/cloudwatch/log-groups/actions.go @@ -13,24 +13,6 @@ import ( func init() { action.Global.Register("cloudwatch", "log-groups", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 1h --follow`, - }, - { - Name: action.ActionNameViewRecent1h, - Shortcut: "1", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 1h | less -R`, - }, - { - Name: action.ActionNameViewRecent24h, - Shortcut: "2", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 24h | less -R`, - }, { Name: "Delete", Shortcut: "D", diff --git a/custom/cloudwatch/log-groups/render.go b/custom/cloudwatch/log-groups/render.go index 2527160e..788ad8dc 100644 --- a/custom/cloudwatch/log-groups/render.go +++ b/custom/cloudwatch/log-groups/render.go @@ -34,14 +34,14 @@ func NewLogGroupRenderer() render.Renderer { } func getSize(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { return render.FormatSize(lg.StoredBytes()) } return "-" } func getRetention(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { days := lg.RetentionDays() if days == 0 { return "Never" @@ -55,7 +55,7 @@ func getRetention(r dao.Resource) string { } func getClass(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { class := lg.LogGroupClass() if class == "" || class == "STANDARD" { return "Standard" @@ -66,7 +66,7 @@ func getClass(r dao.Resource) string { } func getAge(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { creationTime := lg.CreationTime() if creationTime > 0 { t := time.UnixMilli(creationTime) @@ -78,7 +78,7 @@ func getAge(r dao.Resource) string { // RenderDetail renders detailed log group information func (r *LogGroupRenderer) RenderDetail(resource dao.Resource) string { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return "" } @@ -138,7 +138,7 @@ func (r *LogGroupRenderer) RenderDetail(resource dao.Resource) string { // RenderSummary returns summary fields for the header panel func (r *LogGroupRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return r.BaseRenderer.RenderSummary(resource) } @@ -172,14 +172,18 @@ func (r *LogGroupRenderer) RenderSummary(resource dao.Resource) []render.Summary return fields } -// Navigations returns navigation shortcuts func (r *LogGroupRenderer) Navigations(resource dao.Resource) []render.Navigation { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return nil } return []render.Navigation{ + { + Key: "t", + Label: "Tail", + ViewType: render.ViewTypeLogView, + }, { Key: "s", Label: "Streams", diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 24a3f81b..39226dfe 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -12,26 +12,7 @@ import ( ) func init() { - // Register actions for CloudWatch Log Streams action.Global.Register("cloudwatch", "log-streams", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 1h --follow`, - }, - { - Name: action.ActionNameViewRecent1h, - Shortcut: "1", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 1h | less -R`, - }, - { - Name: action.ActionNameViewRecent24h, - Shortcut: "2", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 24h | less -R`, - }, { Name: "Delete", Shortcut: "D", @@ -41,11 +22,9 @@ func init() { }, }) - // Register executor action.RegisterExecutor("cloudwatch", "log-streams", executeLogStreamAction) } -// executeLogStreamAction executes an action on a CloudWatch Log Stream func executeLogStreamAction(ctx context.Context, act action.Action, resource dao.Resource) action.ActionResult { switch act.Operation { case "DeleteLogStream": @@ -60,7 +39,7 @@ func getCloudWatchLogsClient(ctx context.Context) (*cloudwatchlogs.Client, error } func executeDeleteLogStream(ctx context.Context, resource dao.Resource) action.ActionResult { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return action.InvalidResourceResult() } diff --git a/custom/cloudwatch/log-streams/dao.go b/custom/cloudwatch/log-streams/dao.go index c632b867..b684efa4 100644 --- a/custom/cloudwatch/log-streams/dao.go +++ b/custom/cloudwatch/log-streams/dao.go @@ -117,6 +117,9 @@ func (d *LogStreamDAO) Delete(ctx context.Context, id string) error { _, err := d.client.DeleteLogStream(ctx, input) if err != nil { + if apperrors.IsNotFound(err) { + return nil + } return apperrors.Wrapf(err, "delete log stream %s", id) } diff --git a/custom/cloudwatch/log-streams/render.go b/custom/cloudwatch/log-streams/render.go index 1a49714b..cdbe88f0 100644 --- a/custom/cloudwatch/log-streams/render.go +++ b/custom/cloudwatch/log-streams/render.go @@ -31,7 +31,7 @@ func NewLogStreamRenderer() render.Renderer { } func getLastEvent(r dao.Resource) string { - if ls, ok := r.(*LogStreamResource); ok { + if ls, ok := dao.UnwrapResource(r).(*LogStreamResource); ok { lastEvent := ls.LastEventTimestamp() if lastEvent > 0 { t := time.UnixMilli(lastEvent) @@ -42,7 +42,7 @@ func getLastEvent(r dao.Resource) string { } func getAge(r dao.Resource) string { - if ls, ok := r.(*LogStreamResource); ok { + if ls, ok := dao.UnwrapResource(r).(*LogStreamResource); ok { creationTime := ls.CreationTime() if creationTime > 0 { t := time.UnixMilli(creationTime) @@ -54,7 +54,7 @@ func getAge(r dao.Resource) string { // RenderDetail renders detailed log stream information func (r *LogStreamRenderer) RenderDetail(resource dao.Resource) string { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return "" } @@ -95,7 +95,7 @@ func (r *LogStreamRenderer) RenderDetail(resource dao.Resource) string { // RenderSummary returns summary fields for the header panel func (r *LogStreamRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return r.BaseRenderer.RenderSummary(resource) } @@ -118,14 +118,18 @@ func (r *LogStreamRenderer) RenderSummary(resource dao.Resource) []render.Summar return fields } -// Navigations returns navigation shortcuts func (r *LogStreamRenderer) Navigations(resource dao.Resource) []render.Navigation { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return nil } return []render.Navigation{ + { + Key: "t", + Label: "Tail", + ViewType: render.ViewTypeLogView, + }, { Key: "g", Label: "Log Group", diff --git a/internal/action/action.go b/internal/action/action.go index b74664eb..4a656b77 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -44,7 +44,6 @@ type ActionType string const ( ActionTypeExec ActionType = "exec" ActionTypeAPI ActionType = "api" - ActionTypeView ActionType = "view" ) type ConfirmLevel int @@ -55,15 +54,9 @@ const ( ConfirmDangerous ) -// Action names - used for read-only allowlist and cross-package references const ( ActionNameSSOLogin = "SSO Login" - ActionNameLogin = "Login" // :login command - console login - - // Read-only safe exec actions (read-only operations) - ActionNameTailLogs = "Tail Logs" - ActionNameViewRecent1h = "View Recent (1h)" - ActionNameViewRecent24h = "View Recent (24h)" + ActionNameLogin = "Login" ) type Action struct { @@ -72,7 +65,6 @@ type Action struct { Type ActionType Command string Operation string - Target string Confirm ConfirmLevel // SkipAWSEnv skips AWS env injection for exec commands. @@ -171,28 +163,14 @@ var ReadOnlyAllowlist = map[string]bool{ "InvokeFunctionDryRun": true, } -// ReadOnlyExecAllowlist defines exec actions allowed in read-only mode. -// Auth workflows and read-only operations are allowed. -// Arbitrary shells (ECS Exec, SSM Session) are denied - they provide -// interactive access that could modify resources. -// -// Security rationale for each allowed action: var ReadOnlyExecAllowlist = map[string]bool{ - // SSO Login: Authentication workflow, no resource changes ActionNameSSOLogin: true, - // Login: Opens browser for console login, no resource changes - ActionNameLogin: true, - // Log viewing: Read-only CloudWatch Logs access - ActionNameTailLogs: true, - ActionNameViewRecent1h: true, - ActionNameViewRecent24h: true, + ActionNameLogin: true, } // IsAllowedInReadOnly returns whether the action can be executed in read-only mode. func IsAllowedInReadOnly(act Action) bool { switch act.Type { - case ActionTypeView: - return true case ActionTypeExec: return ReadOnlyExecAllowlist[act.Name] case ActionTypeAPI: diff --git a/internal/action/action_test.go b/internal/action/action_test.go index d0d17611..358715af 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -254,7 +254,6 @@ func TestIsAllowedInReadOnly(t *testing.T) { act Action want bool }{ - {"view type allowed", Action{Type: ActionTypeView}, true}, {"exec allowlisted", Action{Type: ActionTypeExec, Name: ActionNameLogin}, true}, {"exec not allowlisted", Action{Type: ActionTypeExec, Name: "SomeExec"}, false}, {"api allowlisted", Action{Type: ActionTypeAPI, Operation: "DetectStackDrift"}, true}, @@ -410,7 +409,6 @@ func TestActionType(t *testing.T) { }{ {ActionTypeExec, "exec"}, {ActionTypeAPI, "api"}, - {ActionTypeView, "view"}, } for _, tt := range tests { @@ -592,7 +590,6 @@ func TestAction_Struct(t *testing.T) { Type: ActionTypeAPI, Command: "test cmd", Operation: "TestOp", - Target: "ec2/instances", Confirm: ConfirmSimple, } @@ -870,25 +867,6 @@ func TestReadOnlyEnforcement_ExecuteWithDAO(t *testing.T) { } }) - t.Run("read-only always allows view actions", func(t *testing.T) { - // Enable read-only mode - config.Global().SetReadOnly(true) - defer config.Global().SetReadOnly(false) - - action := Action{ - Name: "View Details", - Type: ActionTypeView, - Target: "ec2/instances", - } - - result := ExecuteWithDAO(context.Background(), action, &mockResource{id: "test"}, "ec2", "instances") - - // View actions are always allowed - should not return ErrReadOnlyDenied - if result.Error == ErrReadOnlyDenied { - t.Error("read-only should not block view actions") - } - }) - t.Run("non-read-only allows all actions", func(t *testing.T) { // Ensure read-only mode is disabled config.Global().SetReadOnly(false) diff --git a/internal/app/app.go b/internal/app/app.go index dd8bb945..18ec69bf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -212,7 +212,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.Quit): switch a.currentView.(type) { - case *view.DetailView, *view.DiffView: + case *view.DetailView, *view.DiffView, *view.LogView: if len(a.viewStack) > 0 { a.currentView = a.viewStack[len(a.viewStack)-1] a.viewStack = a.viewStack[:len(a.viewStack)-1] diff --git a/internal/aws/helpers.go b/internal/aws/helpers.go index 41bd1059..4d09bdc3 100644 --- a/internal/aws/helpers.go +++ b/internal/aws/helpers.go @@ -53,6 +53,11 @@ func Int32Ptr(i int32) *int32 { return aws.Int32(i) } +// Int64Ptr returns a pointer to the given int64. +func Int64Ptr(i int64) *int64 { + return aws.Int64(i) +} + // ExtractResourceName extracts the resource name from an AWS ARN. // e.g., "arn:aws:iam::123456789012:role/MyRole" -> "MyRole" // e.g., "arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster" -> "my-cluster" diff --git a/internal/config/file.go b/internal/config/file.go index 00e84f6a..62b98762 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -16,6 +16,7 @@ const ( DefaultMultiRegionFetchTimeout = 30 * time.Second DefaultTagSearchTimeout = 30 * time.Second DefaultMetricsLoadTimeout = 30 * time.Second + DefaultLogFetchTimeout = 10 * time.Second DefaultMetricsWindow = 15 * time.Minute DefaultMaxConcurrentFetches = 50 ) @@ -41,6 +42,7 @@ type TimeoutConfig struct { MultiRegionFetch Duration `yaml:"multi_region_fetch,omitempty"` TagSearch Duration `yaml:"tag_search,omitempty"` MetricsLoad Duration `yaml:"metrics_load,omitempty"` + LogFetch Duration `yaml:"log_fetch,omitempty"` } type CloudWatchConfig struct { @@ -105,6 +107,7 @@ func DefaultFileConfig() *FileConfig { MultiRegionFetch: Duration(DefaultMultiRegionFetchTimeout), TagSearch: Duration(DefaultTagSearchTimeout), MetricsLoad: Duration(DefaultMetricsLoadTimeout), + LogFetch: Duration(DefaultLogFetchTimeout), }, Concurrency: ConcurrencyConfig{ MaxFetches: DefaultMaxConcurrentFetches, @@ -224,6 +227,9 @@ func (c *FileConfig) applyDefaults() { if c.Timeouts.MetricsLoad <= 0 { c.Timeouts.MetricsLoad = Duration(DefaultMetricsLoadTimeout) } + if c.Timeouts.LogFetch <= 0 { + c.Timeouts.LogFetch = Duration(DefaultLogFetchTimeout) + } if c.CloudWatch.Window <= 0 { c.CloudWatch.Window = Duration(DefaultMetricsWindow) } @@ -268,6 +274,15 @@ func (c *FileConfig) MetricsLoadTimeout() time.Duration { }) } +func (c *FileConfig) LogFetchTimeout() time.Duration { + return withRLock(&c.mu, func() time.Duration { + if c.Timeouts.LogFetch == 0 { + return DefaultLogFetchTimeout + } + return c.Timeouts.LogFetch.Duration() + }) +} + func (c *FileConfig) MaxConcurrentFetches() int { return withRLock(&c.mu, func() int { if c.Concurrency.MaxFetches == 0 { diff --git a/internal/render/render.go b/internal/render/render.go index 2bc5662c..8d02823a 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -26,16 +26,20 @@ type SummaryField struct { Style lipgloss.Style // Optional styling for the value } -// Navigation defines a navigation shortcut to related resources +// ViewTypeLogView indicates navigation should open a LogView instead of ResourceBrowser +const ViewTypeLogView = "log-view" + +// Navigation defines a navigation shortcut to related resources or custom views type Navigation struct { - Key string // Shortcut key (e.g., "s" for subnets) - Label string // Display label (e.g., "Subnets") - Service string // Target service (e.g., "vpc") - Resource string // Target resource type (e.g., "subnets") - FilterField string // Field name to filter by (e.g., "VpcId") - FilterValue string // Value to filter by (extracted from current resource) - AutoReload bool // Enable auto-reload for this navigation - ReloadInterval time.Duration // Auto-reload interval (default: 3s) + Key string + Label string + Service string + Resource string + FilterField string + FilterValue string + AutoReload bool + ReloadInterval time.Duration + ViewType string } // Renderer defines the interface for rendering resources in table format diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index 55b18205..c0b19ad3 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -246,10 +246,7 @@ func (m *ActionMenu) getConfirmToken(act action.Action) string { func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { if act.Type == action.ActionTypeExec { - // Record action for post-exec follow-up handling m.lastExecAction = &act - - // For exec actions, use tea.Exec to suspend bubbletea execCmd, err := action.ExpandVariables(act.Command, m.resource) if err != nil { return m, func() tea.Msg { @@ -273,11 +270,8 @@ func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { }) } - // For other actions, execute directly result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) m.result = &result - - // If action has a follow-up message, send it if result.FollowUpMsg != nil { log.Debug("action has follow-up message", "action", act.Name, "msgType", fmt.Sprintf("%T", result.FollowUpMsg)) return m, func() tea.Msg { return result.FollowUpMsg } diff --git a/internal/view/log_view.go b/internal/view/log_view.go new file mode 100644 index 00000000..7d317245 --- /dev/null +++ b/internal/view/log_view.go @@ -0,0 +1,432 @@ +package view + +import ( + "context" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/config" + apperrors "github.com/clawscli/claws/internal/errors" + "github.com/clawscli/claws/internal/log" + "github.com/clawscli/claws/internal/ui" +) + +const ( + defaultLogPollInterval = 3 * time.Second + maxLogPollInterval = 30 * time.Second + initialLogBufferSize = 500 + maxLogBufferSize = 1000 + logFetchLimit = 100 + viewportHeaderOffset = 4 // header(1) + status(2) + spacing(1) +) + +type LogView struct { + ctx context.Context + client *cloudwatchlogs.Client + logGroupName string + logStreamName string + + viewport viewport.Model + spinner spinner.Model + styles logViewStyles + + logs []logEntry + loading bool + paused bool + err error + ready bool + width int + height int + + lastEventTime int64 + oldestEventTime int64 + pollInterval time.Duration +} + +type logEntry struct { + timestamp time.Time + message string +} + +type logViewStyles struct { + header lipgloss.Style + timestamp lipgloss.Style + message lipgloss.Style + paused lipgloss.Style + error lipgloss.Style + dim lipgloss.Style +} + +func newLogViewStyles() logViewStyles { + t := ui.Current() + return logViewStyles{ + header: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), + timestamp: lipgloss.NewStyle().Foreground(t.Secondary), + message: lipgloss.NewStyle().Foreground(t.Text), + paused: lipgloss.NewStyle().Bold(true).Foreground(t.Warning), + error: lipgloss.NewStyle().Foreground(t.Danger), + dim: lipgloss.NewStyle().Foreground(t.TextDim), + } +} + +func NewLogView(ctx context.Context, logGroupName string) *LogView { + return &LogView{ + ctx: ctx, + logGroupName: logGroupName, + spinner: ui.NewSpinner(), + styles: newLogViewStyles(), + logs: make([]logEntry, 0, initialLogBufferSize), + loading: true, + pollInterval: defaultLogPollInterval, + } +} + +func NewLogViewWithStream(ctx context.Context, logGroupName, logStreamName string, lastEventTime int64) *LogView { + v := NewLogView(ctx, logGroupName) + v.logStreamName = logStreamName + if lastEventTime > 0 { + v.lastEventTime = lastEventTime - time.Hour.Milliseconds() + } + return v +} + +type logsLoadedMsg struct { + entries []logEntry + lastEventTime int64 + err error + throttled bool + older bool +} + +type logTickMsg time.Time + +func (v *LogView) Init() tea.Cmd { + return tea.Batch( + v.initClient, + v.spinner.Tick, + ) +} + +func (v *LogView) initClient() tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err} + } + cfg, err := appaws.NewConfig(v.ctx) + if err != nil { + return logsLoadedMsg{err: apperrors.Wrap(err, "init AWS config")} + } + v.client = cloudwatchlogs.NewFromConfig(cfg) + return v.doFetchLogs(v.lastEventTime, 0, false) +} + +func (v *LogView) fetchLogsCmd() tea.Cmd { + startTime := v.lastEventTime + return func() tea.Msg { + return v.doFetchLogs(startTime, 0, false) + } +} + +func (v *LogView) fetchOlderLogsCmd() tea.Cmd { + endTime := v.oldestEventTime + if endTime == 0 { + return nil + } + return func() tea.Msg { + return v.doFetchLogs(0, endTime, true) + } +} + +func (v *LogView) doFetchLogs(startTime, endTime int64, older bool) tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err, older: older} + } + if v.client == nil { + return logsLoadedMsg{ + err: apperrors.Wrap(fmt.Errorf("CloudWatch Logs client not initialized"), "fetch logs"), + older: older, + } + } + + ctx, cancel := context.WithTimeout(v.ctx, config.File().LogFetchTimeout()) + defer cancel() + + input := &cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: appaws.StringPtr(v.logGroupName), + Limit: appaws.Int32Ptr(logFetchLimit), + } + + if v.logStreamName != "" { + input.LogStreamNames = []string{v.logStreamName} + } + + if older { + input.StartTime = appaws.Int64Ptr(endTime - time.Hour.Milliseconds()) + input.EndTime = appaws.Int64Ptr(endTime - 1) + } else if startTime > 0 { + input.StartTime = appaws.Int64Ptr(startTime + 1) + } else { + input.StartTime = appaws.Int64Ptr(time.Now().Add(-1 * time.Hour).UnixMilli()) + } + + output, err := v.client.FilterLogEvents(ctx, input) + if err != nil { + return v.handleFetchError(err, older) + } + + return v.processLogEvents(output.Events, older) +} + +func (v *LogView) handleFetchError(err error, older bool) logsLoadedMsg { + var wrappedErr error + throttled := apperrors.IsThrottling(err) + + switch { + case apperrors.IsNotFound(err): + if v.logStreamName != "" { + wrappedErr = apperrors.Wrap(err, "log stream not found") + } else { + wrappedErr = apperrors.Wrap(err, "log group not found") + } + case apperrors.IsAccessDenied(err): + wrappedErr = apperrors.Wrap(err, "access denied to CloudWatch Logs") + default: + wrappedErr = apperrors.Wrap(err, "filter log events") + } + + return logsLoadedMsg{err: wrappedErr, throttled: throttled, older: older} +} + +func (v *LogView) processLogEvents(events []types.FilteredLogEvent, older bool) logsLoadedMsg { + var boundaryTime int64 + entries := make([]logEntry, 0, len(events)) + + for _, event := range events { + ts := time.UnixMilli(appaws.Int64(event.Timestamp)) + msg := appaws.Str(event.Message) + entries = append(entries, logEntry{ + timestamp: ts, + message: strings.TrimSuffix(msg, "\n"), + }) + + eventTs := appaws.Int64(event.Timestamp) + if older { + if boundaryTime == 0 || eventTs < boundaryTime { + boundaryTime = eventTs + } + } else { + if eventTs > boundaryTime { + boundaryTime = eventTs + } + } + } + + return logsLoadedMsg{entries: entries, lastEventTime: boundaryTime, older: older} +} + +func (v *LogView) tickCmd() tea.Cmd { + return tea.Tick(v.pollInterval, func(t time.Time) tea.Msg { + return logTickMsg(t) + }) +} + +func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case logsLoadedMsg: + v.loading = false + if msg.err != nil { + log.Warn("failed to fetch log events", "error", msg.err) + v.err = msg.err + if msg.throttled { + v.pollInterval = min(v.pollInterval*2, maxLogPollInterval) + log.Info("throttled, backing off", "interval", v.pollInterval) + if !v.paused && !msg.older { + return v, v.tickCmd() + } + } + return v, nil + } + v.pollInterval = defaultLogPollInterval + v.err = nil + if msg.older { + if len(msg.entries) > 0 { + v.logs = append(msg.entries, v.logs...) + if len(v.logs) > maxLogBufferSize { + v.logs = v.logs[:maxLogBufferSize] + } + if msg.lastEventTime > 0 { + v.oldestEventTime = msg.lastEventTime + } + if v.ready { + v.updateViewportContent() + } + } + return v, nil + } + if msg.lastEventTime > v.lastEventTime { + v.lastEventTime = msg.lastEventTime + } + if len(msg.entries) > 0 { + if v.oldestEventTime == 0 && len(msg.entries) > 0 { + v.oldestEventTime = msg.entries[0].timestamp.UnixMilli() + } + v.logs = append(v.logs, msg.entries...) + if len(v.logs) > maxLogBufferSize { + v.logs = v.logs[len(v.logs)-maxLogBufferSize:] + } + if v.ready { + v.updateViewportContent() + v.viewport.GotoBottom() + } + } + if !v.paused { + return v, v.tickCmd() + } + return v, nil + + case logTickMsg: + if v.paused { + return v, nil + } + return v, v.fetchLogsCmd() + + case tea.KeyPressMsg: + switch msg.String() { + case "space": + v.paused = !v.paused + if !v.paused { + return v, v.tickCmd() + } + return v, nil + case "g": + if v.ready { + v.viewport.GotoTop() + } + return v, nil + case "G": + if v.ready { + v.viewport.GotoBottom() + } + return v, nil + case "c": + v.logs = v.logs[:0] + v.oldestEventTime = 0 + if v.ready { + v.updateViewportContent() + } + return v, nil + case "p": + if v.oldestEventTime > 0 && !v.loading { + v.loading = true + return v, v.fetchOlderLogsCmd() + } + return v, nil + } + + case spinner.TickMsg: + if v.loading { + var cmd tea.Cmd + v.spinner, cmd = v.spinner.Update(msg) + return v, cmd + } + } + + if v.ready { + var cmd tea.Cmd + v.viewport, cmd = v.viewport.Update(msg) + return v, cmd + } + return v, nil +} + +func (v *LogView) updateViewportContent() { + var sb strings.Builder + for _, entry := range v.logs { + ts := v.styles.timestamp.Render(entry.timestamp.Format("15:04:05.000")) + msg := v.styles.message.Render(entry.message) + sb.WriteString(fmt.Sprintf("%s %s\n", ts, msg)) + } + v.viewport.SetContent(sb.String()) +} + +func (v *LogView) ViewString() string { + if !v.ready { + return "Loading..." + } + + var sb strings.Builder + + title := v.logGroupName + if v.logStreamName != "" { + title = fmt.Sprintf("%s / %s", v.logGroupName, v.logStreamName) + } + sb.WriteString(v.styles.header.Render("📜 " + title)) + sb.WriteString("\n") + + if v.paused { + sb.WriteString(v.styles.paused.Render("⏸ PAUSED")) + sb.WriteString(" ") + } + sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d lines)", len(v.logs)))) + sb.WriteString("\n\n") + + if v.loading { + sb.WriteString(v.spinner.View()) + sb.WriteString(" Loading logs...") + return sb.String() + } + + if v.err != nil { + sb.WriteString(v.styles.error.Render(fmt.Sprintf("Error: %v", v.err))) + return sb.String() + } + + if len(v.logs) == 0 { + sb.WriteString(v.styles.dim.Render("No log events found in the last hour")) + return sb.String() + } + + sb.WriteString(v.viewport.View()) + return sb.String() +} + +func (v *LogView) View() tea.View { + return tea.NewView(v.ViewString()) +} + +func (v *LogView) SetSize(width, height int) tea.Cmd { + v.width = width + v.height = height + viewportHeight := height - viewportHeaderOffset + + if !v.ready { + v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) + v.ready = true + } else { + v.viewport.SetWidth(width) + v.viewport.SetHeight(viewportHeight) + } + + v.updateViewportContent() + return nil +} + +func (v *LogView) StatusLine() string { + status := "Space:pause/resume p:older g/G:top/bottom c:clear Esc:back" + if v.paused { + return "⏸ PAUSED • " + status + } + if v.pollInterval > defaultLogPollInterval { + return fmt.Sprintf("⏳ THROTTLED (%ds) • %s", int(v.pollInterval.Seconds()), status) + } + return "▶ STREAMING • " + status +} diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go new file mode 100644 index 00000000..635023f5 --- /dev/null +++ b/internal/view/log_view_test.go @@ -0,0 +1,286 @@ +package view + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestNewLogView(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/lambda/my-function") + + if lv.logGroupName != "/aws/lambda/my-function" { + t.Errorf("logGroupName = %q, want %q", lv.logGroupName, "/aws/lambda/my-function") + } + if lv.logStreamName != "" { + t.Errorf("logStreamName = %q, want empty", lv.logStreamName) + } + if !lv.loading { + t.Error("Expected loading to be true initially") + } + if lv.paused { + t.Error("Expected paused to be false initially") + } + if lv.pollInterval != defaultLogPollInterval { + t.Errorf("pollInterval = %v, want %v", lv.pollInterval, defaultLogPollInterval) + } +} + +func TestNewLogViewWithStream(t *testing.T) { + ctx := context.Background() + lv := NewLogViewWithStream(ctx, "/aws/lambda/my-function", "2024/01/01/[$LATEST]abc123", 0) + + if lv.logGroupName != "/aws/lambda/my-function" { + t.Errorf("logGroupName = %q, want %q", lv.logGroupName, "/aws/lambda/my-function") + } + if lv.logStreamName != "2024/01/01/[$LATEST]abc123" { + t.Errorf("logStreamName = %q, want %q", lv.logStreamName, "2024/01/01/[$LATEST]abc123") + } +} + +func TestLogViewLogsLoadedSuccess(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + entries := []logEntry{ + {timestamp: time.Now(), message: "log line 1"}, + {timestamp: time.Now(), message: "log line 2"}, + } + msg := logsLoadedMsg{entries: entries, lastEventTime: 1234567890} + + lv.Update(msg) + + if lv.loading { + t.Error("Expected loading to be false after logsLoadedMsg") + } + if len(lv.logs) != 2 { + t.Errorf("len(logs) = %d, want 2", len(lv.logs)) + } + if lv.lastEventTime != 1234567890 { + t.Errorf("lastEventTime = %d, want 1234567890", lv.lastEventTime) + } + if lv.err != nil { + t.Errorf("err = %v, want nil", lv.err) + } +} + +func TestLogViewLogsLoadedError(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + msg := logsLoadedMsg{err: fmt.Errorf("access denied")} + + lv.Update(msg) + + if lv.loading { + t.Error("Expected loading to be false after error") + } + if lv.err == nil { + t.Error("Expected err to be set after error message") + } +} + +func TestLogViewBufferTrimming(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + for i := 0; i < 999; i++ { + lv.logs = append(lv.logs, logEntry{ + timestamp: time.Now(), + message: fmt.Sprintf("line %d", i), + }) + } + + newEntries := make([]logEntry, 10) + for i := 0; i < 10; i++ { + newEntries[i] = logEntry{timestamp: time.Now(), message: fmt.Sprintf("new line %d", i)} + } + msg := logsLoadedMsg{entries: newEntries, lastEventTime: 1} + + lv.Update(msg) + + if len(lv.logs) != 1000 { + t.Errorf("len(logs) = %d, want 1000 (buffer should trim to max)", len(lv.logs)) + } + + if !strings.Contains(lv.logs[0].message, "line 9") { + t.Errorf("first log = %q, expected oldest kept entry 'line 9'", lv.logs[0].message) + } +} + +func TestLogViewPauseToggle(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + if lv.paused { + t.Error("Expected paused to be false initially") + } + + spaceMsg := tea.KeyPressMsg{Code: tea.KeySpace} + lv.Update(spaceMsg) + + if !lv.paused { + t.Error("Expected paused to be true after first space") + } + + lv.Update(spaceMsg) + + if lv.paused { + t.Error("Expected paused to be false after second space") + } +} + +func TestLogViewClearLogs(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.logs = []logEntry{ + {timestamp: time.Now(), message: "line 1"}, + {timestamp: time.Now(), message: "line 2"}, + } + + cMsg := tea.KeyPressMsg{Code: 0, Text: "c"} + lv.Update(cMsg) + + if len(lv.logs) != 0 { + t.Errorf("len(logs) = %d, want 0 after clear", len(lv.logs)) + } +} + +func TestLogViewTickWhenPaused(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.paused = true + + tickMsg := logTickMsg(time.Now()) + _, cmd := lv.Update(tickMsg) + + if cmd != nil { + t.Error("Expected nil cmd when paused (no fetch should be triggered)") + } +} + +func TestLogViewStatusLine(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + + streamingStatus := lv.StatusLine() + if !strings.Contains(streamingStatus, "STREAMING") { + t.Errorf("StatusLine() = %q, want to contain 'STREAMING'", streamingStatus) + } + + lv.paused = true + pausedStatus := lv.StatusLine() + if !strings.Contains(pausedStatus, "PAUSED") { + t.Errorf("StatusLine() = %q, want to contain 'PAUSED'", pausedStatus) + } +} + +func TestLogViewViewStringStates(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + setup func(*LogView) + wantContain string + }{ + { + name: "loading state", + setup: func(lv *LogView) { lv.loading = true }, + wantContain: "Loading", + }, + { + name: "error state", + setup: func(lv *LogView) { + lv.loading = false + lv.err = fmt.Errorf("test error") + }, + wantContain: "Error", + }, + { + name: "empty state", + setup: func(lv *LogView) { + lv.loading = false + }, + wantContain: "No log events", + }, + { + name: "paused state", + setup: func(lv *LogView) { + lv.loading = false + lv.paused = true + }, + wantContain: "PAUSED", + }, + { + name: "with stream name in title", + setup: func(lv *LogView) { + lv.loading = false + lv.logStreamName = "my-stream" + }, + wantContain: "my-stream", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + tt.setup(lv) + + view := lv.ViewString() + if !strings.Contains(view, tt.wantContain) { + t.Errorf("ViewString() = %q, want to contain %q", view, tt.wantContain) + } + }) + } +} + +func TestLogViewSetSize(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + + cmd := lv.SetSize(120, 40) + + if cmd != nil { + t.Error("Expected SetSize to return nil cmd") + } + if !lv.ready { + t.Error("Expected ready to be true after SetSize") + } +} + +func TestLogViewGotoTopBottom(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + for i := 0; i < 50; i++ { + lv.logs = append(lv.logs, logEntry{ + timestamp: time.Now(), + message: fmt.Sprintf("line %d", i), + }) + } + lv.updateViewportContent() + + gMsg := tea.KeyPressMsg{Code: 0, Text: "g"} + lv.Update(gMsg) + + GMsg := tea.KeyPressMsg{Code: 0, Text: "G"} + lv.Update(GMsg) +} diff --git a/internal/view/view.go b/internal/view/view.go index a488d082..adbd8470 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -138,6 +138,10 @@ func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd navigations := navigator.Navigations(resource) for _, nav := range navigations { if nav.Key == key { + if nav.ViewType != "" { + return h.createCustomView(nav, resource) + } + var newBrowser *ResourceBrowser if nav.AutoReload { interval := nav.ReloadInterval @@ -171,3 +175,41 @@ func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd return nil } + +func (h *NavigationHelper) createCustomView(nav render.Navigation, resource dao.Resource) tea.Cmd { + switch nav.ViewType { + case render.ViewTypeLogView: + return h.createLogView(resource) + default: + return nil + } +} + +func (h *NavigationHelper) createLogView(resource dao.Resource) tea.Cmd { + var logView *LogView + + type logGroupProvider interface{ LogGroupName() string } + type logStreamProvider interface{ LogStreamName() string } + type lastEventProvider interface{ LastEventTimestamp() int64 } + + unwrapped := dao.UnwrapResource(resource) + + if p, ok := unwrapped.(logGroupProvider); ok { + logGroupName := p.LogGroupName() + if sp, ok := unwrapped.(logStreamProvider); ok { + var lastEvent int64 + if lp, ok := unwrapped.(lastEventProvider); ok { + lastEvent = lp.LastEventTimestamp() + } + logView = NewLogViewWithStream(h.Ctx, logGroupName, sp.LogStreamName(), lastEvent) + } else { + logView = NewLogView(h.Ctx, logGroupName) + } + } else { + logView = NewLogView(h.Ctx, unwrapped.GetID()) + } + + return func() tea.Msg { + return NavigateMsg{View: logView} + } +}