From eaa73d708a6e3c9f68626963c3917e5df5c3a5ee Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 07:51:22 +0000 Subject: [PATCH 01/18] feat: native CloudWatch Logs viewer (issue #29) Replace external 'aws logs tail' dependency with internal TUI log viewer. - Add LogView with FilterLogEvents polling (3s interval) - Support both log-groups and log-streams - Space to pause/resume, g/G for top/bottom, c to clear - Remove AWS CLI and less dependencies --- custom/cloudwatch/log-groups/actions.go | 16 +- custom/cloudwatch/log-streams/actions.go | 17 +- internal/action/action.go | 8 +- internal/view/action_menu.go | 59 ++++- internal/view/log_view.go | 288 +++++++++++++++++++++++ 5 files changed, 344 insertions(+), 44 deletions(-) create mode 100644 internal/view/log_view.go diff --git a/custom/cloudwatch/log-groups/actions.go b/custom/cloudwatch/log-groups/actions.go index c2b0d4db..bfc9ae50 100644 --- a/custom/cloudwatch/log-groups/actions.go +++ b/custom/cloudwatch/log-groups/actions.go @@ -16,20 +16,8 @@ func init() { { 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`, + Type: action.ActionTypeView, + Target: action.ViewTargetLogView, }, { Name: "Delete", diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 24a3f81b..1ee2bf09 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -12,25 +12,12 @@ 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`, + Type: action.ActionTypeView, + Target: action.ViewTargetLogView, }, { Name: "Delete", diff --git a/internal/action/action.go b/internal/action/action.go index b74664eb..484e7775 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -55,17 +55,19 @@ const ( ConfirmDangerous ) -// Action names - used for read-only allowlist and cross-package references const ( ActionNameSSOLogin = "SSO Login" - ActionNameLogin = "Login" // :login command - console login + ActionNameLogin = "Login" - // Read-only safe exec actions (read-only operations) ActionNameTailLogs = "Tail Logs" ActionNameViewRecent1h = "View Recent (1h)" ActionNameViewRecent24h = "View Recent (24h)" ) +const ( + ViewTargetLogView = "log-view" +) + type Action struct { Name string Shortcut string diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index 55b18205..51fb6f9f 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -245,11 +245,9 @@ 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 + switch act.Type { + case action.ActionTypeExec: 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 { @@ -271,18 +269,55 @@ func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { } return execResultMsg{success: true, message: "Session ended"} }) + + case action.ActionTypeView: + return m.executeViewAction(act) + + default: + result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) + m.result = &result + 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 } + } + return m, nil + } +} + +func (m *ActionMenu) executeViewAction(act action.Action) (tea.Model, tea.Cmd) { + switch act.Target { + case action.ViewTargetLogView: + return m.openLogView() + default: + m.result = &action.ActionResult{ + Success: false, + Error: fmt.Errorf("unknown view target: %s", act.Target), + } + return m, nil } +} - // For other actions, execute directly - result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) - m.result = &result +func (m *ActionMenu) openLogView() (tea.Model, tea.Cmd) { + var logView *LogView - // 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 } + if provider, ok := m.resource.(action.LogGroupNameProvider); ok { + logGroupName := provider.LogGroupName() + if streamProvider, ok := m.resource.(logStreamNameProvider); ok { + logView = NewLogViewWithStream(m.ctx, logGroupName, streamProvider.LogStreamName()) + } else { + logView = NewLogView(m.ctx, logGroupName) + } + } else { + logView = NewLogView(m.ctx, m.resource.GetID()) } - return m, nil + + return m, func() tea.Msg { + return NavigateMsg{View: logView} + } +} + +type logStreamNameProvider interface { + LogStreamName() string } // execResultMsg is sent when an exec action completes diff --git a/internal/view/log_view.go b/internal/view/log_view.go new file mode 100644 index 00000000..ca6c117d --- /dev/null +++ b/internal/view/log_view.go @@ -0,0 +1,288 @@ +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/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/ui" +) + +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 + width, height int + + lastEventTime 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 { + s := spinner.New() + s.Spinner = spinner.Dot + + return &LogView{ + ctx: ctx, + logGroupName: logGroupName, + spinner: s, + styles: newLogViewStyles(), + logs: make([]logEntry, 0, 500), + loading: true, + pollInterval: 3 * time.Second, + } +} + +func NewLogViewWithStream(ctx context.Context, logGroupName, logStreamName string) *LogView { + v := NewLogView(ctx, logGroupName) + v.logStreamName = logStreamName + return v +} + +type logsLoadedMsg struct { + entries []logEntry + err error +} + +type logTickMsg time.Time + +func (v *LogView) Init() tea.Cmd { + return tea.Batch( + v.initClient, + v.spinner.Tick, + ) +} + +func (v *LogView) initClient() tea.Msg { + cfg, err := appaws.NewConfig(v.ctx) + if err != nil { + return logsLoadedMsg{err: fmt.Errorf("init AWS config: %w", err)} + } + v.client = cloudwatchlogs.NewFromConfig(cfg) + return v.fetchLogs() +} + +func (v *LogView) fetchLogs() tea.Msg { + if v.client == nil { + return logsLoadedMsg{err: fmt.Errorf("client not initialized")} + } + + input := &cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: aws.String(v.logGroupName), + Limit: aws.Int32(100), + } + + if v.logStreamName != "" { + input.LogStreamNames = []string{v.logStreamName} + } + + if v.lastEventTime > 0 { + input.StartTime = aws.Int64(v.lastEventTime + 1) + } else { + input.StartTime = aws.Int64(time.Now().Add(-1 * time.Hour).UnixMilli()) + } + + output, err := v.client.FilterLogEvents(v.ctx, input) + if err != nil { + return logsLoadedMsg{err: fmt.Errorf("filter log events: %w", err)} + } + + entries := make([]logEntry, 0, len(output.Events)) + for _, event := range output.Events { + ts := time.UnixMilli(aws.ToInt64(event.Timestamp)) + msg := aws.ToString(event.Message) + entries = append(entries, logEntry{ + timestamp: ts, + message: strings.TrimSuffix(msg, "\n"), + }) + if aws.ToInt64(event.Timestamp) > v.lastEventTime { + v.lastEventTime = aws.ToInt64(event.Timestamp) + } + } + + return logsLoadedMsg{entries: entries} +} + +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 { + v.err = msg.err + return v, nil + } + if len(msg.entries) > 0 { + v.logs = append(v.logs, msg.entries...) + if len(v.logs) > 1000 { + v.logs = v.logs[len(v.logs)-1000:] + } + 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, func() tea.Msg { return v.fetchLogs() } + + case tea.KeyPressMsg: + switch msg.String() { + case " ": + v.paused = !v.paused + if !v.paused { + return v, v.tickCmd() + } + return v, nil + case "g": + v.viewport.GotoTop() + return v, nil + case "G": + v.viewport.GotoBottom() + return v, nil + case "c": + v.logs = v.logs[:0] + v.updateViewportContent() + return v, nil + } + + case spinner.TickMsg: + if v.loading { + var cmd tea.Cmd + v.spinner, cmd = v.spinner.Update(msg) + return v, cmd + } + } + + var cmd tea.Cmd + v.viewport, cmd = v.viewport.Update(msg) + return v, cmd +} + +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 { + 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 - 4 + v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) + v.updateViewportContent() + return nil +} + +func (v *LogView) StatusLine() string { + status := "Space:pause/resume g/G:top/bottom c:clear Esc:back" + if v.paused { + return "⏸ PAUSED • " + status + } + return "▶ STREAMING • " + status +} + +func (v *LogView) HasActiveInput() bool { + return false +} From 4aa454273ceb125f0ccef765515bd6601502db3d Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 08:03:37 +0000 Subject: [PATCH 02/18] refactor: use Navigation instead of Action for log viewing Move log viewing from Action menu to Navigation shortcut: - Press 't' directly from log-groups/log-streams to open LogView - Remove ActionTypeView handling from action_menu - Cleaner UX: no action menu step needed --- custom/cloudwatch/log-groups/actions.go | 6 --- custom/cloudwatch/log-groups/render.go | 6 ++- custom/cloudwatch/log-streams/actions.go | 8 ---- custom/cloudwatch/log-streams/render.go | 6 ++- internal/action/action.go | 22 +--------- internal/app/app.go | 2 +- internal/render/render.go | 20 +++++---- internal/view/action_menu.go | 55 +++--------------------- internal/view/log_view.go | 2 +- internal/view/view.go | 36 +++++++++++++++- 10 files changed, 66 insertions(+), 97 deletions(-) diff --git a/custom/cloudwatch/log-groups/actions.go b/custom/cloudwatch/log-groups/actions.go index bfc9ae50..eb02b42f 100644 --- a/custom/cloudwatch/log-groups/actions.go +++ b/custom/cloudwatch/log-groups/actions.go @@ -13,12 +13,6 @@ import ( func init() { action.Global.Register("cloudwatch", "log-groups", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeView, - Target: action.ViewTargetLogView, - }, { Name: "Delete", Shortcut: "D", diff --git a/custom/cloudwatch/log-groups/render.go b/custom/cloudwatch/log-groups/render.go index 2527160e..ec55fd0e 100644 --- a/custom/cloudwatch/log-groups/render.go +++ b/custom/cloudwatch/log-groups/render.go @@ -172,7 +172,6 @@ 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) if !ok { @@ -180,6 +179,11 @@ func (r *LogGroupRenderer) Navigations(resource dao.Resource) []render.Navigatio } 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 1ee2bf09..27d5395e 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -13,12 +13,6 @@ import ( func init() { action.Global.Register("cloudwatch", "log-streams", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeView, - Target: action.ViewTargetLogView, - }, { Name: "Delete", Shortcut: "D", @@ -28,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": diff --git a/custom/cloudwatch/log-streams/render.go b/custom/cloudwatch/log-streams/render.go index 1a49714b..2a3713ed 100644 --- a/custom/cloudwatch/log-streams/render.go +++ b/custom/cloudwatch/log-streams/render.go @@ -118,7 +118,6 @@ 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) if !ok { @@ -126,6 +125,11 @@ func (r *LogStreamRenderer) Navigations(resource dao.Resource) []render.Navigati } 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 484e7775..b2e30dcb 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -58,14 +58,6 @@ const ( const ( ActionNameSSOLogin = "SSO Login" ActionNameLogin = "Login" - - ActionNameTailLogs = "Tail Logs" - ActionNameViewRecent1h = "View Recent (1h)" - ActionNameViewRecent24h = "View Recent (24h)" -) - -const ( - ViewTargetLogView = "log-view" ) type Action struct { @@ -173,21 +165,9 @@ 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. 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/render/render.go b/internal/render/render.go index 2bc5662c..40aabe91 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -26,16 +26,18 @@ type SummaryField struct { Style lipgloss.Style // Optional styling for the value } -// Navigation defines a navigation shortcut to related resources +const ViewTypeLogView = "log-view" + 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 51fb6f9f..c0b19ad3 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -245,8 +245,7 @@ func (m *ActionMenu) getConfirmToken(act action.Action) string { } func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { - switch act.Type { - case action.ActionTypeExec: + if act.Type == action.ActionTypeExec { m.lastExecAction = &act execCmd, err := action.ExpandVariables(act.Command, m.resource) if err != nil { @@ -269,55 +268,15 @@ func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { } return execResultMsg{success: true, message: "Session ended"} }) - - case action.ActionTypeView: - return m.executeViewAction(act) - - default: - result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) - m.result = &result - 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 } - } - return m, nil - } -} - -func (m *ActionMenu) executeViewAction(act action.Action) (tea.Model, tea.Cmd) { - switch act.Target { - case action.ViewTargetLogView: - return m.openLogView() - default: - m.result = &action.ActionResult{ - Success: false, - Error: fmt.Errorf("unknown view target: %s", act.Target), - } - return m, nil } -} - -func (m *ActionMenu) openLogView() (tea.Model, tea.Cmd) { - var logView *LogView - if provider, ok := m.resource.(action.LogGroupNameProvider); ok { - logGroupName := provider.LogGroupName() - if streamProvider, ok := m.resource.(logStreamNameProvider); ok { - logView = NewLogViewWithStream(m.ctx, logGroupName, streamProvider.LogStreamName()) - } else { - logView = NewLogView(m.ctx, logGroupName) - } - } else { - logView = NewLogView(m.ctx, m.resource.GetID()) + result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) + m.result = &result + 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 } } - - return m, func() tea.Msg { - return NavigateMsg{View: logView} - } -} - -type logStreamNameProvider interface { - LogStreamName() string + return m, nil } // execResultMsg is sent when an exec action completes diff --git a/internal/view/log_view.go b/internal/view/log_view.go index ca6c117d..3bd5bc19 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -184,7 +184,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch msg.String() { - case " ": + case "space": v.paused = !v.paused if !v.paused { return v, v.tickCmd() diff --git a/internal/view/view.go b/internal/view/view.go index a488d082..be39d950 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -124,7 +124,6 @@ func (h *NavigationHelper) FormatShortcuts(resource dao.Resource) string { return strings.Join(parts, " ") } -// HandleKey handles navigation key press and returns a command if navigation occurred func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd { if h.Renderer == nil || h.Registry == nil { return nil @@ -138,6 +137,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 +174,34 @@ 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 } + + if p, ok := resource.(logGroupProvider); ok { + logGroupName := p.LogGroupName() + if sp, ok := resource.(logStreamProvider); ok { + logView = NewLogViewWithStream(h.Ctx, logGroupName, sp.LogStreamName()) + } else { + logView = NewLogView(h.Ctx, logGroupName) + } + } else { + logView = NewLogView(h.Ctx, resource.GetID()) + } + + return func() tea.Msg { + return NavigateMsg{View: logView} + } +} From 47b7bc52cb45f8e55669d205d0e5c4824c011b64 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 10:16:51 +0000 Subject: [PATCH 03/18] test: add LogView tests + fix data race in fetchLogs --- internal/view/log_view.go | 37 ++-- internal/view/log_view_test.go | 298 +++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 internal/view/log_view_test.go diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 3bd5bc19..0dcabd0a 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -18,6 +18,8 @@ import ( "github.com/clawscli/claws/internal/ui" ) +const defaultLogPollInterval = 3 * time.Second + type LogView struct { ctx context.Context client *cloudwatchlogs.Client @@ -75,7 +77,7 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { styles: newLogViewStyles(), logs: make([]logEntry, 0, 500), loading: true, - pollInterval: 3 * time.Second, + pollInterval: defaultLogPollInterval, } } @@ -86,8 +88,9 @@ func NewLogViewWithStream(ctx context.Context, logGroupName, logStreamName strin } type logsLoadedMsg struct { - entries []logEntry - err error + entries []logEntry + lastEventTime int64 + err error } type logTickMsg time.Time @@ -105,10 +108,18 @@ func (v *LogView) initClient() tea.Msg { return logsLoadedMsg{err: fmt.Errorf("init AWS config: %w", err)} } v.client = cloudwatchlogs.NewFromConfig(cfg) - return v.fetchLogs() + return v.fetchLogs(v.lastEventTime) } -func (v *LogView) fetchLogs() tea.Msg { +// fetchLogsCmd captures lastEventTime to avoid data race in the command goroutine. +func (v *LogView) fetchLogsCmd() tea.Cmd { + startTime := v.lastEventTime + return func() tea.Msg { + return v.fetchLogs(startTime) + } +} + +func (v *LogView) fetchLogs(startTime int64) tea.Msg { if v.client == nil { return logsLoadedMsg{err: fmt.Errorf("client not initialized")} } @@ -122,8 +133,8 @@ func (v *LogView) fetchLogs() tea.Msg { input.LogStreamNames = []string{v.logStreamName} } - if v.lastEventTime > 0 { - input.StartTime = aws.Int64(v.lastEventTime + 1) + if startTime > 0 { + input.StartTime = aws.Int64(startTime + 1) } else { input.StartTime = aws.Int64(time.Now().Add(-1 * time.Hour).UnixMilli()) } @@ -133,6 +144,7 @@ func (v *LogView) fetchLogs() tea.Msg { return logsLoadedMsg{err: fmt.Errorf("filter log events: %w", err)} } + var maxEventTime int64 entries := make([]logEntry, 0, len(output.Events)) for _, event := range output.Events { ts := time.UnixMilli(aws.ToInt64(event.Timestamp)) @@ -141,12 +153,12 @@ func (v *LogView) fetchLogs() tea.Msg { timestamp: ts, message: strings.TrimSuffix(msg, "\n"), }) - if aws.ToInt64(event.Timestamp) > v.lastEventTime { - v.lastEventTime = aws.ToInt64(event.Timestamp) + if eventTs := aws.ToInt64(event.Timestamp); eventTs > maxEventTime { + maxEventTime = eventTs } } - return logsLoadedMsg{entries: entries} + return logsLoadedMsg{entries: entries, lastEventTime: maxEventTime} } func (v *LogView) tickCmd() tea.Cmd { @@ -163,6 +175,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.err = msg.err return v, nil } + if msg.lastEventTime > v.lastEventTime { + v.lastEventTime = msg.lastEventTime + } if len(msg.entries) > 0 { v.logs = append(v.logs, msg.entries...) if len(v.logs) > 1000 { @@ -180,7 +195,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if v.paused { return v, nil } - return v, func() tea.Msg { return v.fetchLogs() } + return v, v.fetchLogsCmd() case tea.KeyPressMsg: switch msg.String() { diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go new file mode 100644 index 00000000..2fc05beb --- /dev/null +++ b/internal/view/log_view_test.go @@ -0,0 +1,298 @@ +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") + + 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 TestLogViewHasActiveInput(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + + if lv.HasActiveInput() { + t.Error("Expected HasActiveInput() to return false") + } +} + +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.width != 120 { + t.Errorf("width = %d, want 120", lv.width) + } + if lv.height != 40 { + t.Errorf("height = %d, want 40", lv.height) + } +} + +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) +} From 8330b74f303382f8cf309113065db514525aead6 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 10:27:02 +0000 Subject: [PATCH 04/18] fix: add ready flag to LogView, remove ActionTypeView dead code - Add ready flag to LogView to prevent uninitialized viewport access - Remove ActionTypeView constant and related IsAllowedInReadOnly case - Remove obsolete ActionTypeView test cases - Remove beads issue tracking setup (not using) --- internal/action/action.go | 3 --- internal/action/action_test.go | 21 --------------------- internal/view/log_view.go | 33 +++++++++++++++++++++++++-------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/internal/action/action.go b/internal/action/action.go index b2e30dcb..d2d85aa7 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 @@ -173,8 +172,6 @@ var ReadOnlyExecAllowlist = map[string]bool{ // 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..f86a1c06 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 { @@ -870,25 +868,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/view/log_view.go b/internal/view/log_view.go index 0dcabd0a..f0f6a9b7 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -34,6 +34,7 @@ type LogView struct { loading bool paused bool err error + ready bool width, height int lastEventTime int64 @@ -183,8 +184,10 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(v.logs) > 1000 { v.logs = v.logs[len(v.logs)-1000:] } - v.updateViewportContent() - v.viewport.GotoBottom() + if v.ready { + v.updateViewportContent() + v.viewport.GotoBottom() + } } if !v.paused { return v, v.tickCmd() @@ -206,14 +209,20 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return v, nil case "g": - v.viewport.GotoTop() + if v.ready { + v.viewport.GotoTop() + } return v, nil case "G": - v.viewport.GotoBottom() + if v.ready { + v.viewport.GotoBottom() + } return v, nil case "c": v.logs = v.logs[:0] - v.updateViewportContent() + if v.ready { + v.updateViewportContent() + } return v, nil } @@ -225,9 +234,12 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - var cmd tea.Cmd - v.viewport, cmd = v.viewport.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() { @@ -273,6 +285,10 @@ func (v *LogView) ViewString() string { return sb.String() } + if !v.ready { + return sb.String() + } + sb.WriteString(v.viewport.View()) return sb.String() } @@ -286,6 +302,7 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { v.height = height viewportHeight := height - 4 v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) + v.ready = true v.updateViewportContent() return nil } From 135d97ec42c0f276f48f11fd828f6606fe6c1be4 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 10:53:07 +0000 Subject: [PATCH 05/18] fix: align LogView with codebase patterns (error handling, AWS helpers) - LogView: use apperrors.Wrap instead of fmt.Errorf - LogView: use appaws.* helpers instead of raw aws.* - LogStreamDAO.Delete: add IsNotFound check for idempotency - helpers.go: add Int64Ptr for consistency with Int32Ptr --- custom/cloudwatch/log-streams/dao.go | 3 +++ internal/aws/helpers.go | 5 +++++ internal/view/log_view.go | 23 ++++++++++++----------- 3 files changed, 20 insertions(+), 11 deletions(-) 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/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/view/log_view.go b/internal/view/log_view.go index f0f6a9b7..b0449154 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -2,6 +2,7 @@ package view import ( "context" + "errors" "fmt" "strings" "time" @@ -11,10 +12,10 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" appaws "github.com/clawscli/claws/internal/aws" + apperrors "github.com/clawscli/claws/internal/errors" "github.com/clawscli/claws/internal/ui" ) @@ -106,7 +107,7 @@ func (v *LogView) Init() tea.Cmd { func (v *LogView) initClient() tea.Msg { cfg, err := appaws.NewConfig(v.ctx) if err != nil { - return logsLoadedMsg{err: fmt.Errorf("init AWS config: %w", err)} + return logsLoadedMsg{err: apperrors.Wrap(err, "init AWS config")} } v.client = cloudwatchlogs.NewFromConfig(cfg) return v.fetchLogs(v.lastEventTime) @@ -122,12 +123,12 @@ func (v *LogView) fetchLogsCmd() tea.Cmd { func (v *LogView) fetchLogs(startTime int64) tea.Msg { if v.client == nil { - return logsLoadedMsg{err: fmt.Errorf("client not initialized")} + return logsLoadedMsg{err: errors.New("client not initialized")} } input := &cloudwatchlogs.FilterLogEventsInput{ - LogGroupName: aws.String(v.logGroupName), - Limit: aws.Int32(100), + LogGroupName: appaws.StringPtr(v.logGroupName), + Limit: appaws.Int32Ptr(100), } if v.logStreamName != "" { @@ -135,26 +136,26 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { } if startTime > 0 { - input.StartTime = aws.Int64(startTime + 1) + input.StartTime = appaws.Int64Ptr(startTime + 1) } else { - input.StartTime = aws.Int64(time.Now().Add(-1 * time.Hour).UnixMilli()) + input.StartTime = appaws.Int64Ptr(time.Now().Add(-1 * time.Hour).UnixMilli()) } output, err := v.client.FilterLogEvents(v.ctx, input) if err != nil { - return logsLoadedMsg{err: fmt.Errorf("filter log events: %w", err)} + return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events")} } var maxEventTime int64 entries := make([]logEntry, 0, len(output.Events)) for _, event := range output.Events { - ts := time.UnixMilli(aws.ToInt64(event.Timestamp)) - msg := aws.ToString(event.Message) + ts := time.UnixMilli(appaws.Int64(event.Timestamp)) + msg := appaws.Str(event.Message) entries = append(entries, logEntry{ timestamp: ts, message: strings.TrimSuffix(msg, "\n"), }) - if eventTs := aws.ToInt64(event.Timestamp); eventTs > maxEventTime { + if eventTs := appaws.Int64(event.Timestamp); eventTs > maxEventTime { maxEventTime = eventTs } } From 5ad05731e93e81cc4897a978aadfa400d13d69d5 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:28:01 +0000 Subject: [PATCH 06/18] fix: LogView SetSize/ViewString patterns for consistency with other views --- internal/view/log_view.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index b0449154..73ff19c2 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -16,6 +16,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" apperrors "github.com/clawscli/claws/internal/errors" + "github.com/clawscli/claws/internal/log" "github.com/clawscli/claws/internal/ui" ) @@ -174,6 +175,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case logsLoadedMsg: v.loading = false if msg.err != nil { + log.Warn("failed to fetch log events", "error", msg.err) v.err = msg.err return v, nil } @@ -254,6 +256,10 @@ func (v *LogView) updateViewportContent() { } func (v *LogView) ViewString() string { + if !v.ready { + return "Loading..." + } + var sb strings.Builder title := v.logGroupName @@ -286,10 +292,6 @@ func (v *LogView) ViewString() string { return sb.String() } - if !v.ready { - return sb.String() - } - sb.WriteString(v.viewport.View()) return sb.String() } @@ -302,8 +304,15 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { v.width = width v.height = height viewportHeight := height - 4 - v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - v.ready = true + + 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 } From fa0e52149258fbe7a92ae13f557e0f838a6e9faa Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:39:14 +0000 Subject: [PATCH 07/18] fix: add context timeout and throttling backoff to LogView - Add 10s timeout for FilterLogEvents API calls - Add exponential backoff on throttling (up to 30s) - Reset poll interval on success - Add godoc for LogView struct --- internal/view/log_view.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 73ff19c2..5719cd85 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -20,8 +20,14 @@ import ( "github.com/clawscli/claws/internal/ui" ) -const defaultLogPollInterval = 3 * time.Second +const ( + defaultLogPollInterval = 3 * time.Second + maxLogPollInterval = 30 * time.Second + logFetchTimeout = 10 * time.Second +) +// LogView displays CloudWatch Logs with real-time streaming via polling. +// Supports pause/resume, scroll, and clear operations. type LogView struct { ctx context.Context client *cloudwatchlogs.Client @@ -94,6 +100,7 @@ type logsLoadedMsg struct { entries []logEntry lastEventTime int64 err error + throttled bool } type logTickMsg time.Time @@ -127,6 +134,9 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { return logsLoadedMsg{err: errors.New("client not initialized")} } + ctx, cancel := context.WithTimeout(v.ctx, logFetchTimeout) + defer cancel() + input := &cloudwatchlogs.FilterLogEventsInput{ LogGroupName: appaws.StringPtr(v.logGroupName), Limit: appaws.Int32Ptr(100), @@ -142,9 +152,10 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { input.StartTime = appaws.Int64Ptr(time.Now().Add(-1 * time.Hour).UnixMilli()) } - output, err := v.client.FilterLogEvents(v.ctx, input) + output, err := v.client.FilterLogEvents(ctx, input) if err != nil { - return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events")} + throttled := apperrors.IsThrottling(err) + return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events"), throttled: throttled} } var maxEventTime int64 @@ -177,8 +188,16 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { + return v, v.tickCmd() + } return v, nil } + v.pollInterval = defaultLogPollInterval if msg.lastEventTime > v.lastEventTime { v.lastEventTime = msg.lastEventTime } From 032dde64a91332ed3a55b3315a0aac3d5aa45f3b Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:42:12 +0000 Subject: [PATCH 08/18] fix: clear error state on successful log fetch --- .gtrconfig | 9 +++++++++ internal/view/log_view.go | 1 + 2 files changed, 10 insertions(+) create mode 100644 .gtrconfig diff --git a/.gtrconfig b/.gtrconfig new file mode 100644 index 00000000..d2f90019 --- /dev/null +++ b/.gtrconfig @@ -0,0 +1,9 @@ +[hooks] + postCreate = go mod download + postCreate = gtr-link-agents + postCreate = ln -sf "$REPO_ROOT/.claude" "$WORKTREE_PATH/.claude" +[defaults] + ai = opencode +[gtr] + defaultBranch = develop + diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 5719cd85..15469fc0 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -198,6 +198,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil } v.pollInterval = defaultLogPollInterval + v.err = nil if msg.lastEventTime > v.lastEventTime { v.lastEventTime = msg.lastEventTime } From 0ac490b195211b67cb8022dee3c573c38125c977 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:42:20 +0000 Subject: [PATCH 09/18] chore: ignore .gtrconfig --- .gitignore | 1 + .gtrconfig | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 .gtrconfig 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/.gtrconfig b/.gtrconfig deleted file mode 100644 index d2f90019..00000000 --- a/.gtrconfig +++ /dev/null @@ -1,9 +0,0 @@ -[hooks] - postCreate = go mod download - postCreate = gtr-link-agents - postCreate = ln -sf "$REPO_ROOT/.claude" "$WORKTREE_PATH/.claude" -[defaults] - ai = opencode -[gtr] - defaultBranch = develop - From 576085a1478a6912ffd20953080b87dfba1d2fd5 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:45:51 +0000 Subject: [PATCH 10/18] fix: add context cancellation check and extract magic numbers - Check parent context before starting fetch to prevent resource leaks - Extract buffer sizes and fetch limit to named constants --- internal/view/log_view.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 15469fc0..31b5b9ab 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -24,6 +24,9 @@ const ( defaultLogPollInterval = 3 * time.Second maxLogPollInterval = 30 * time.Second logFetchTimeout = 10 * time.Second + initialLogBufferSize = 500 + maxLogBufferSize = 1000 + logFetchLimit = 100 ) // LogView displays CloudWatch Logs with real-time streaming via polling. @@ -84,7 +87,7 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { logGroupName: logGroupName, spinner: s, styles: newLogViewStyles(), - logs: make([]logEntry, 0, 500), + logs: make([]logEntry, 0, initialLogBufferSize), loading: true, pollInterval: defaultLogPollInterval, } @@ -130,6 +133,9 @@ func (v *LogView) fetchLogsCmd() tea.Cmd { } func (v *LogView) fetchLogs(startTime int64) tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err} + } if v.client == nil { return logsLoadedMsg{err: errors.New("client not initialized")} } @@ -139,7 +145,7 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { input := &cloudwatchlogs.FilterLogEventsInput{ LogGroupName: appaws.StringPtr(v.logGroupName), - Limit: appaws.Int32Ptr(100), + Limit: appaws.Int32Ptr(logFetchLimit), } if v.logStreamName != "" { @@ -204,8 +210,8 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if len(msg.entries) > 0 { v.logs = append(v.logs, msg.entries...) - if len(v.logs) > 1000 { - v.logs = v.logs[len(v.logs)-1000:] + if len(v.logs) > maxLogBufferSize { + v.logs = v.logs[len(v.logs)-maxLogBufferSize:] } if v.ready { v.updateViewportContent() From cf784635d1dfd85bba6a7ad82b6654dc8e81e68c Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:48:42 +0000 Subject: [PATCH 11/18] fix: stop polling on non-throttling errors Only continue polling with backoff on throttling errors. Other errors (access denied, etc.) stop polling to avoid spamming. --- internal/view/log_view.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 31b5b9ab..747ff2db 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -197,9 +197,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.throttled { v.pollInterval = min(v.pollInterval*2, maxLogPollInterval) log.Info("throttled, backing off", "interval", v.pollInterval) - } - if !v.paused { - return v, v.tickCmd() + if !v.paused { + return v, v.tickCmd() + } } return v, nil } From 7dbcb9e1e3175dc8481068f755f1f48dc89ad33c Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:54:28 +0000 Subject: [PATCH 12/18] fix: add context check to initClient and throttle indicator - Check context cancellation before initializing AWS client - Show throttle status with backoff interval in StatusLine --- internal/view/log_view.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 747ff2db..c08d7613 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -116,6 +116,9 @@ func (v *LogView) Init() tea.Cmd { } 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")} @@ -348,6 +351,9 @@ func (v *LogView) StatusLine() string { if v.paused { return "⏸ PAUSED • " + status } + if v.pollInterval > defaultLogPollInterval { + return fmt.Sprintf("⏳ THROTTLED (%ds) • %s", int(v.pollInterval.Seconds()), status) + } return "▶ STREAMING • " + status } From b94d3e1317a4a043de38aa7044a33212e841a52d Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 11:58:39 +0000 Subject: [PATCH 13/18] fix: unwrap resource before type assertion in createLogView Fixes multi-region mode where resources are wrapped with RegionalDAOWrapper. Without unwrapping, type assertions for logGroupProvider/logStreamProvider fail. --- internal/view/view.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/view/view.go b/internal/view/view.go index be39d950..a8e533fe 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -190,15 +190,17 @@ func (h *NavigationHelper) createLogView(resource dao.Resource) tea.Cmd { type logGroupProvider interface{ LogGroupName() string } type logStreamProvider interface{ LogStreamName() string } - if p, ok := resource.(logGroupProvider); ok { + unwrapped := dao.UnwrapResource(resource) + + if p, ok := unwrapped.(logGroupProvider); ok { logGroupName := p.LogGroupName() - if sp, ok := resource.(logStreamProvider); ok { + if sp, ok := unwrapped.(logStreamProvider); ok { logView = NewLogViewWithStream(h.Ctx, logGroupName, sp.LogStreamName()) } else { logView = NewLogView(h.Ctx, logGroupName) } } else { - logView = NewLogView(h.Ctx, resource.GetID()) + logView = NewLogView(h.Ctx, unwrapped.GetID()) } return func() tea.Msg { From 987ddec711af12e8e738c69be3420f193a90659e Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 12:07:32 +0000 Subject: [PATCH 14/18] feat: add 'p' key to load older logs and fix old stream viewing - Add 'p' key to load 1 hour of older logs (prepends to buffer) - For log streams, use lastEventTimestamp as starting point - Track oldestEventTime to enable backward navigation - Fixes issue where old streams showed 'no log events' --- internal/view/log_view.go | 96 ++++++++++++++++++++++++++++++++-- internal/view/log_view_test.go | 2 +- internal/view/view.go | 7 ++- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index c08d7613..0404bed9 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -48,8 +48,9 @@ type LogView struct { ready bool width, height int - lastEventTime int64 - pollInterval time.Duration + lastEventTime int64 + oldestEventTime int64 + pollInterval time.Duration } type logEntry struct { @@ -93,9 +94,12 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { } } -func NewLogViewWithStream(ctx context.Context, logGroupName, logStreamName string) *LogView { +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 } @@ -104,6 +108,7 @@ type logsLoadedMsg struct { lastEventTime int64 err error throttled bool + older bool } type logTickMsg time.Time @@ -184,6 +189,62 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { return logsLoadedMsg{entries: entries, lastEventTime: maxEventTime} } +func (v *LogView) fetchOlderLogsCmd() tea.Cmd { + endTime := v.oldestEventTime + if endTime == 0 { + return nil + } + return func() tea.Msg { + return v.fetchOlderLogs(endTime) + } +} + +func (v *LogView) fetchOlderLogs(endTime int64) tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err, older: true} + } + if v.client == nil { + return logsLoadedMsg{err: errors.New("client not initialized"), older: true} + } + + ctx, cancel := context.WithTimeout(v.ctx, logFetchTimeout) + defer cancel() + + input := &cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: appaws.StringPtr(v.logGroupName), + Limit: appaws.Int32Ptr(logFetchLimit), + StartTime: appaws.Int64Ptr(endTime - time.Hour.Milliseconds()), + EndTime: appaws.Int64Ptr(endTime - 1), + } + + if v.logStreamName != "" { + input.LogStreamNames = []string{v.logStreamName} + } + + output, err := v.client.FilterLogEvents(ctx, input) + if err != nil { + throttled := apperrors.IsThrottling(err) + return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events"), throttled: throttled, older: true} + } + + var minEventTime int64 + entries := make([]logEntry, 0, len(output.Events)) + for _, event := range output.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 minEventTime == 0 || eventTs < minEventTime { + minEventTime = eventTs + } + } + + return logsLoadedMsg{entries: entries, lastEventTime: minEventTime, older: true} +} + func (v *LogView) tickCmd() tea.Cmd { return tea.Tick(v.pollInterval, func(t time.Time) tea.Msg { return logTickMsg(t) @@ -200,7 +261,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.throttled { v.pollInterval = min(v.pollInterval*2, maxLogPollInterval) log.Info("throttled, backing off", "interval", v.pollInterval) - if !v.paused { + if !v.paused && !msg.older { return v, v.tickCmd() } } @@ -208,10 +269,28 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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:] @@ -252,10 +331,17 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -347,7 +433,7 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { } func (v *LogView) StatusLine() string { - status := "Space:pause/resume g/G:top/bottom c:clear Esc:back" + status := "Space:pause/resume p:older g/G:top/bottom c:clear Esc:back" if v.paused { return "⏸ PAUSED • " + status } diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 2fc05beb..ce6ac912 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -33,7 +33,7 @@ func TestNewLogView(t *testing.T) { func TestNewLogViewWithStream(t *testing.T) { ctx := context.Background() - lv := NewLogViewWithStream(ctx, "/aws/lambda/my-function", "2024/01/01/[$LATEST]abc123") + 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") diff --git a/internal/view/view.go b/internal/view/view.go index a8e533fe..638eb473 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -189,13 +189,18 @@ func (h *NavigationHelper) createLogView(resource dao.Resource) tea.Cmd { 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 { - logView = NewLogViewWithStream(h.Ctx, logGroupName, sp.LogStreamName()) + 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) } From 520149a182c7d59a0ad48738b00097d41885d4eb Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 12:33:51 +0000 Subject: [PATCH 15/18] refactor: improve LogView consistency and add configurable timeout - Use ui.NewSpinner() for consistent spinner initialization - Extract doFetchLogs() to eliminate code duplication (75% reduction) - Add LogFetchTimeout to config system (default: 10s) - Add IsNotFound/IsAccessDenied error classification for better UX - Replace errors.New() with apperrors.Wrap() for consistency - Document viewportHeaderOffset magic number --- README.md | 1 + internal/config/file.go | 14 ++++ internal/view/log_view.go | 133 +++++++++++++++++--------------------- 3 files changed, 75 insertions(+), 73 deletions(-) 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/internal/config/file.go b/internal/config/file.go index 00e84f6a..cf25c9c2 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 { @@ -224,6 +226,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 +273,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/view/log_view.go b/internal/view/log_view.go index 0404bed9..9feccb36 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -2,7 +2,6 @@ package view import ( "context" - "errors" "fmt" "strings" "time" @@ -13,8 +12,10 @@ import ( "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" @@ -23,14 +24,12 @@ import ( const ( defaultLogPollInterval = 3 * time.Second maxLogPollInterval = 30 * time.Second - logFetchTimeout = 10 * time.Second initialLogBufferSize = 500 maxLogBufferSize = 1000 logFetchLimit = 100 + viewportHeaderOffset = 4 // header(1) + status(2) + spacing(1) ) -// LogView displays CloudWatch Logs with real-time streaming via polling. -// Supports pause/resume, scroll, and clear operations. type LogView struct { ctx context.Context client *cloudwatchlogs.Client @@ -80,13 +79,10 @@ func newLogViewStyles() logViewStyles { } func NewLogView(ctx context.Context, logGroupName string) *LogView { - s := spinner.New() - s.Spinner = spinner.Dot - return &LogView{ ctx: ctx, logGroupName: logGroupName, - spinner: s, + spinner: ui.NewSpinner(), styles: newLogViewStyles(), logs: make([]logEntry, 0, initialLogBufferSize), loading: true, @@ -129,26 +125,38 @@ func (v *LogView) initClient() tea.Msg { return logsLoadedMsg{err: apperrors.Wrap(err, "init AWS config")} } v.client = cloudwatchlogs.NewFromConfig(cfg) - return v.fetchLogs(v.lastEventTime) + return v.doFetchLogs(v.lastEventTime, 0, false) } -// fetchLogsCmd captures lastEventTime to avoid data race in the command goroutine. func (v *LogView) fetchLogsCmd() tea.Cmd { startTime := v.lastEventTime return func() tea.Msg { - return v.fetchLogs(startTime) + return v.doFetchLogs(startTime, 0, false) } } -func (v *LogView) fetchLogs(startTime int64) tea.Msg { +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} + return logsLoadedMsg{err: err, older: older} } if v.client == nil { - return logsLoadedMsg{err: errors.New("client not initialized")} + return logsLoadedMsg{ + err: apperrors.Wrap(fmt.Errorf("CloudWatch Logs client not initialized"), "fetch logs"), + older: older, + } } - ctx, cancel := context.WithTimeout(v.ctx, logFetchTimeout) + ctx, cancel := context.WithTimeout(v.ctx, config.File().LogFetchTimeout()) defer cancel() input := &cloudwatchlogs.FilterLogEventsInput{ @@ -160,7 +168,10 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { input.LogStreamNames = []string{v.logStreamName} } - if startTime > 0 { + 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()) @@ -168,81 +179,57 @@ func (v *LogView) fetchLogs(startTime int64) tea.Msg { output, err := v.client.FilterLogEvents(ctx, input) if err != nil { - throttled := apperrors.IsThrottling(err) - return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events"), throttled: throttled} + return v.handleFetchError(err, older) } - var maxEventTime int64 - entries := make([]logEntry, 0, len(output.Events)) - for _, event := range output.Events { - ts := time.UnixMilli(appaws.Int64(event.Timestamp)) - msg := appaws.Str(event.Message) - entries = append(entries, logEntry{ - timestamp: ts, - message: strings.TrimSuffix(msg, "\n"), - }) - if eventTs := appaws.Int64(event.Timestamp); eventTs > maxEventTime { - maxEventTime = eventTs - } - } - - return logsLoadedMsg{entries: entries, lastEventTime: maxEventTime} -} - -func (v *LogView) fetchOlderLogsCmd() tea.Cmd { - endTime := v.oldestEventTime - if endTime == 0 { - return nil - } - return func() tea.Msg { - return v.fetchOlderLogs(endTime) - } + return v.processLogEvents(output.Events, older) } -func (v *LogView) fetchOlderLogs(endTime int64) tea.Msg { - if err := v.ctx.Err(); err != nil { - return logsLoadedMsg{err: err, older: true} - } - if v.client == nil { - return logsLoadedMsg{err: errors.New("client not initialized"), older: true} - } - - ctx, cancel := context.WithTimeout(v.ctx, logFetchTimeout) - defer cancel() +func (v *LogView) handleFetchError(err error, older bool) logsLoadedMsg { + var wrappedErr error + throttled := apperrors.IsThrottling(err) - input := &cloudwatchlogs.FilterLogEventsInput{ - LogGroupName: appaws.StringPtr(v.logGroupName), - Limit: appaws.Int32Ptr(logFetchLimit), - StartTime: appaws.Int64Ptr(endTime - time.Hour.Milliseconds()), - EndTime: appaws.Int64Ptr(endTime - 1), + 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") } - if v.logStreamName != "" { - input.LogStreamNames = []string{v.logStreamName} - } + return logsLoadedMsg{err: wrappedErr, throttled: throttled, older: older} +} - output, err := v.client.FilterLogEvents(ctx, input) - if err != nil { - throttled := apperrors.IsThrottling(err) - return logsLoadedMsg{err: apperrors.Wrap(err, "filter log events"), throttled: throttled, older: true} - } +func (v *LogView) processLogEvents(events []types.FilteredLogEvent, older bool) logsLoadedMsg { + var boundaryTime int64 + entries := make([]logEntry, 0, len(events)) - var minEventTime int64 - entries := make([]logEntry, 0, len(output.Events)) - for _, event := range output.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 minEventTime == 0 || eventTs < minEventTime { - minEventTime = eventTs + if older { + if boundaryTime == 0 || eventTs < boundaryTime { + boundaryTime = eventTs + } + } else { + if eventTs > boundaryTime { + boundaryTime = eventTs + } } } - return logsLoadedMsg{entries: entries, lastEventTime: minEventTime, older: true} + return logsLoadedMsg{entries: entries, lastEventTime: boundaryTime, older: older} } func (v *LogView) tickCmd() tea.Cmd { @@ -418,7 +405,7 @@ func (v *LogView) View() tea.View { func (v *LogView) SetSize(width, height int) tea.Cmd { v.width = width v.height = height - viewportHeight := height - 4 + viewportHeight := height - viewportHeaderOffset if !v.ready { v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) From cab3c7d65d9c74decc98c3d8ee581d735cfa923d Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 12:44:43 +0000 Subject: [PATCH 16/18] refactor: remove dead code and restore doc comments - Remove unused Action.Target field - Remove unused LogView.width/height fields - Add LogFetch to DefaultFileConfig for consistency - Restore doc comments for Navigation, ViewTypeLogView, HandleKey --- internal/action/action.go | 1 - internal/action/action_test.go | 1 - internal/config/file.go | 1 + internal/render/render.go | 2 ++ internal/view/log_view.go | 13 +++++-------- internal/view/log_view_test.go | 7 ++----- internal/view/view.go | 1 + 7 files changed, 11 insertions(+), 15 deletions(-) diff --git a/internal/action/action.go b/internal/action/action.go index d2d85aa7..4a656b77 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -65,7 +65,6 @@ type Action struct { Type ActionType Command string Operation string - Target string Confirm ConfirmLevel // SkipAWSEnv skips AWS env injection for exec commands. diff --git a/internal/action/action_test.go b/internal/action/action_test.go index f86a1c06..358715af 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -590,7 +590,6 @@ func TestAction_Struct(t *testing.T) { Type: ActionTypeAPI, Command: "test cmd", Operation: "TestOp", - Target: "ec2/instances", Confirm: ConfirmSimple, } diff --git a/internal/config/file.go b/internal/config/file.go index cf25c9c2..62b98762 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -107,6 +107,7 @@ func DefaultFileConfig() *FileConfig { MultiRegionFetch: Duration(DefaultMultiRegionFetchTimeout), TagSearch: Duration(DefaultTagSearchTimeout), MetricsLoad: Duration(DefaultMetricsLoadTimeout), + LogFetch: Duration(DefaultLogFetchTimeout), }, Concurrency: ConcurrencyConfig{ MaxFetches: DefaultMaxConcurrentFetches, diff --git a/internal/render/render.go b/internal/render/render.go index 40aabe91..8d02823a 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -26,8 +26,10 @@ type SummaryField struct { Style lipgloss.Style // Optional styling for the value } +// 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 Label string diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 9feccb36..67616bf8 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -40,12 +40,11 @@ type LogView struct { spinner spinner.Model styles logViewStyles - logs []logEntry - loading bool - paused bool - err error - ready bool - width, height int + logs []logEntry + loading bool + paused bool + err error + ready bool lastEventTime int64 oldestEventTime int64 @@ -403,8 +402,6 @@ func (v *LogView) View() tea.View { } func (v *LogView) SetSize(width, height int) tea.Cmd { - v.width = width - v.height = height viewportHeight := height - viewportHeaderOffset if !v.ready { diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index ce6ac912..7ad5e142 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -268,11 +268,8 @@ func TestLogViewSetSize(t *testing.T) { if cmd != nil { t.Error("Expected SetSize to return nil cmd") } - if lv.width != 120 { - t.Errorf("width = %d, want 120", lv.width) - } - if lv.height != 40 { - t.Errorf("height = %d, want 40", lv.height) + if !lv.ready { + t.Error("Expected ready to be true after SetSize") } } diff --git a/internal/view/view.go b/internal/view/view.go index 638eb473..adbd8470 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -124,6 +124,7 @@ func (h *NavigationHelper) FormatShortcuts(resource dao.Resource) string { return strings.Join(parts, " ") } +// HandleKey handles navigation key press and returns a command if navigation occurred func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd { if h.Renderer == nil || h.Registry == nil { return nil From 2f12d918900144b604e5acaa624079edbc7e5b75 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 12:54:54 +0000 Subject: [PATCH 17/18] fix: add UnwrapResource for multi-region support in cloudwatch resources --- custom/cloudwatch/log-groups/render.go | 14 +++++++------- custom/cloudwatch/log-streams/actions.go | 2 +- custom/cloudwatch/log-streams/render.go | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/custom/cloudwatch/log-groups/render.go b/custom/cloudwatch/log-groups/render.go index ec55fd0e..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) } @@ -173,7 +173,7 @@ func (r *LogGroupRenderer) RenderSummary(resource dao.Resource) []render.Summary } func (r *LogGroupRenderer) Navigations(resource dao.Resource) []render.Navigation { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return nil } diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 27d5395e..39226dfe 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -39,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/render.go b/custom/cloudwatch/log-streams/render.go index 2a3713ed..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) } @@ -119,7 +119,7 @@ func (r *LogStreamRenderer) RenderSummary(resource dao.Resource) []render.Summar } func (r *LogStreamRenderer) Navigations(resource dao.Resource) []render.Navigation { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return nil } From 727504c6c90b70360088726dcaaa68efba52fd87 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 13:04:27 +0000 Subject: [PATCH 18/18] refactor: align LogView with other viewport-based views - Add width/height fields for consistency with DetailView, DiffView, etc. - Remove HasActiveInput() since views without filter don't implement it --- internal/view/log_view.go | 8 ++++---- internal/view/log_view_test.go | 9 --------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 67616bf8..7d317245 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -45,6 +45,8 @@ type LogView struct { paused bool err error ready bool + width int + height int lastEventTime int64 oldestEventTime int64 @@ -402,6 +404,8 @@ func (v *LogView) View() tea.View { } func (v *LogView) SetSize(width, height int) tea.Cmd { + v.width = width + v.height = height viewportHeight := height - viewportHeaderOffset if !v.ready { @@ -426,7 +430,3 @@ func (v *LogView) StatusLine() string { } return "▶ STREAMING • " + status } - -func (v *LogView) HasActiveInput() bool { - return false -} diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 7ad5e142..635023f5 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -190,15 +190,6 @@ func TestLogViewStatusLine(t *testing.T) { } } -func TestLogViewHasActiveInput(t *testing.T) { - ctx := context.Background() - lv := NewLogView(ctx, "/aws/test") - - if lv.HasActiveInput() { - t.Error("Expected HasActiveInput() to return false") - } -} - func TestLogViewViewStringStates(t *testing.T) { ctx := context.Background()