Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eaa73d7
feat: native CloudWatch Logs viewer (issue #29)
yimsk Jan 3, 2026
4aa4542
refactor: use Navigation instead of Action for log viewing
yimsk Jan 3, 2026
47b7bc5
test: add LogView tests + fix data race in fetchLogs
yimsk Jan 3, 2026
8330b74
fix: add ready flag to LogView, remove ActionTypeView dead code
yimsk Jan 3, 2026
135d97e
fix: align LogView with codebase patterns (error handling, AWS helpers)
yimsk Jan 3, 2026
5ad0573
fix: LogView SetSize/ViewString patterns for consistency with other v…
yimsk Jan 3, 2026
fa0e521
fix: add context timeout and throttling backoff to LogView
yimsk Jan 3, 2026
032dde6
fix: clear error state on successful log fetch
yimsk Jan 3, 2026
0ac490b
chore: ignore .gtrconfig
yimsk Jan 3, 2026
576085a
fix: add context cancellation check and extract magic numbers
yimsk Jan 3, 2026
cf78463
fix: stop polling on non-throttling errors
yimsk Jan 3, 2026
7dbcb9e
fix: add context check to initClient and throttle indicator
yimsk Jan 3, 2026
b94d3e1
fix: unwrap resource before type assertion in createLogView
yimsk Jan 3, 2026
987ddec
feat: add 'p' key to load older logs and fix old stream viewing
yimsk Jan 3, 2026
520149a
refactor: improve LogView consistency and add configurable timeout
yimsk Jan 3, 2026
cab3c7d
refactor: remove dead code and restore doc comments
yimsk Jan 3, 2026
2f12d91
fix: add UnwrapResource for multi-region support in cloudwatch resources
yimsk Jan 3, 2026
727504c
refactor: align LogView with other viewport-based views
yimsk Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ claws.log
# Node.js
node_modules/
.claude
.gtrconfig
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 0 additions & 18 deletions custom/cloudwatch/log-groups/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 12 additions & 8 deletions custom/cloudwatch/log-groups/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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 ""
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 1 addition & 22 deletions custom/cloudwatch/log-streams/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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":
Expand All @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions custom/cloudwatch/log-streams/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
16 changes: 10 additions & 6 deletions custom/cloudwatch/log-streams/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 ""
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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",
Expand Down
26 changes: 2 additions & 24 deletions internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ type ActionType string
const (
ActionTypeExec ActionType = "exec"
ActionTypeAPI ActionType = "api"
ActionTypeView ActionType = "view"
)

type ConfirmLevel int
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 0 additions & 22 deletions internal/action/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -410,7 +409,6 @@ func TestActionType(t *testing.T) {
}{
{ActionTypeExec, "exec"},
{ActionTypeAPI, "api"},
{ActionTypeView, "view"},
}

for _, tt := range tests {
Expand Down Expand Up @@ -592,7 +590,6 @@ func TestAction_Struct(t *testing.T) {
Type: ActionTypeAPI,
Command: "test cmd",
Operation: "TestOp",
Target: "ec2/instances",
Confirm: ConfirmSimple,
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions internal/aws/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading