From db8c2581a0c01674b4ef592b28c89fd1ca5dadc6 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 01:42:02 +0900 Subject: [PATCH 1/8] feat: add AWS Backup recovery browser Add paginated Backup vault discovery and a read-only recovery posture detail view with graceful partial-result warnings. Integrate filtering, saved views, context resets, help, tests, and user-facing documentation. Closes #321. --- README.md | 6 +- docs/architecture.en.md | 2 + docs/architecture.ko.md | 2 + docs/project-overview.en.md | 3 +- docs/project-overview.ko.md | 3 +- go.mod | 1 + go.sum | 2 + internal/app/app.go | 6 + internal/app/context_terminal.go | 3 + internal/app/feature_submodel.go | 2 +- internal/app/filter.go | 1 + internal/app/help.go | 4 + internal/app/keymap.go | 15 + internal/app/messages.go | 13 + internal/app/screen_backup.go | 452 ++++++++++++++++++++++++++ internal/app/screen_backup_test.go | 189 +++++++++++ internal/app/screen_context.go | 4 + internal/app/screen_views.go | 1 + internal/domain/catalog.go | 9 + internal/domain/catalog_test.go | 16 + internal/domain/model.go | 2 + internal/services/aws/backup.go | 302 +++++++++++++++++ internal/services/aws/backup_model.go | 95 ++++++ internal/services/aws/backup_test.go | 169 ++++++++++ internal/services/aws/repository.go | 12 + 25 files changed, 1310 insertions(+), 4 deletions(-) create mode 100644 internal/app/screen_backup.go create mode 100644 internal/app/screen_backup_test.go create mode 100644 internal/services/aws/backup.go create mode 100644 internal/services/aws/backup_model.go create mode 100644 internal/services/aws/backup_test.go diff --git a/README.md b/README.md index e5f7d1d..5046fb8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It combines a Bubble Tea application, Cobra-based CLI commands, and AWS SDK v2 c - Drill down into resources with filters, detail views, and action screens - Open a context-aware keyboard shortcut help screen with `?` - Show animated loading indicators while async AWS data is being fetched -- Perform operational workflows such as EC2 inventory inspection, SSM sessions, RDS control, Route53 record changes, DynamoDB table and keyed-item inspection, ECS rollout inspection/exec, EKS cluster and node group review, IAM access key rotation, and Bedrock API key management +- Perform operational workflows such as EC2 inventory inspection, SSM sessions, RDS control, Route53 record changes, DynamoDB table and keyed-item inspection, AWS Backup recovery-readiness review, ECS rollout inspection/exec, EKS cluster and node group review, IAM access key rotation, and Bedrock API key management - Press `i` from the service picker to enter Inspector mode, then run either the Security Inspector workflow for built-in security and cost/waste findings or the Checklist Inspector workflow for YAML-driven readiness checks across databases, network resources, DNS, logging, secrets, and baseline posture. Checklist files can be loaded from the in-TUI picker or preloaded with `--checklist ` ## Documentation Map @@ -329,6 +329,7 @@ Context ordering: | S3 | S3 Browser | | Lambda | Lambda Browser | | DynamoDB | Table Browser & Key Lookup | +| AWS Backup | Recovery Browser | | Bedrock | API Key Manager | | IAM | IAM User Browser | | IAM | ListAccessKeys | @@ -468,6 +469,7 @@ checks: | Step Functions | `/` filter state machines by name/ARN/type/region or executions by status/name/ARN, `r` refresh, `Enter` executions/detail, detail `↑`/`↓` scroll, `PgUp`/`PgDn` page | | Lambda | `A` toggle all-regions scope (multi-region contexts), `Enter` invoke, `d` detail, `l` view CloudWatch Logs, `/` filter, `r` refresh | | DynamoDB | `/` filter, `r` refresh, `Enter` table detail, detail `l` prompts for the complete partition/sort key and performs one `GetItem`, `↑`/`↓` and `PgUp`/`PgDn` scroll details or item JSON | +| AWS Backup | `/` filter vaults, `r` refresh, `Enter` recovery-readiness detail, detail `↑`/`↓` scroll and `PgUp`/`PgDn` page through recovery points, protected resources, and recent failed/expired jobs | The command palette (`P`) fuzzy-searches three kinds of items from anywhere outside text-entry screens: service features (jump straight into a browser), contexts (switch without opening the picker), and resources indexed across services. Opening the palette starts an async index of EC2 instances, RDS instances, Lambda functions, S3 buckets, ECS clusters, and Route53 zones in the current context. Press `Tab` to opt into searching the active context plus sync-managed contexts; context fan-out is bounded, rows show context and region tags, and per-context/service failures are shown inline. Matching covers names, IDs, ARNs, contexts, and regions where available. Selecting a resource in another context switches context and then jumps to the owning browser with the shared filter prefilled to that resource. @@ -501,6 +503,8 @@ The EventBridge Rules Browser lists rules from every event bus with disabled rul The DynamoDB Table Browser lists billing mode, provisioned capacity or on-demand mode, approximate item count and size, and global secondary index count. Table detail shows the primary key schema, GSI schemas, TTL status, and stream configuration. Press `l` to enter the complete partition key and optional sort key; unic performs exactly one `GetItem` and renders the returned item as scrollable JSON. It never scans or queries a table. The active identity needs `dynamodb:ListTables`, `dynamodb:DescribeTable`, `dynamodb:DescribeTimeToLive`, and `dynamodb:GetItem` for this flow. +The AWS Backup Recovery Browser lists vault state, type, recovery-point count, encryption key, and Vault Lock retention metadata in the active region. Opening a vault shows failure/expiry-prioritized recovery points, protected resources with their latest backup, and failed, expired, aborted, partial, or completed-with-issues jobs from AWS Backup's recent job window. Each paginated section is independent: completed pages and other sections remain visible when a later page or one section is denied, with the failure summarized inline. The browser is read-only and requires `backup:ListBackupVaults`, `backup:ListRecoveryPointsByBackupVault`, `backup:ListProtectedResourcesByBackupVault`, and `backup:ListBackupJobs`. + The CloudTrail Event Lookup answers "who changed what, and when": recent API events list newest-first with mutations marked `*`, actor, call, and source service per row. Keys `1`-`5` switch the time window (1h/6h/24h/3d/7d), `m` restricts to mutations (server-side via the `ReadOnly=false` lookup attribute), and `n` runs a server-side resource-name lookup — CloudTrail accepts one lookup attribute per call, so combining both applies the mutations restriction client-side. Results are capped at 100 events per query, so narrow the window or use the resource lookup when a busy account truncates. Event detail shows actor, source, region, source IP, touched resources, and the full raw event JSON with scrolling. The CloudWatch Alarm Browser is an alarm-first incident entry point: alarms list firing-first (ALARM, then INSUFFICIENT_DATA, then OK) with a `tab`-cycled state filter and text filtering across names, states, metrics, and dimensions. Alarm detail shows the state reason, condition, dimensions, and the most recent state transitions. When an alarm's dimensions map to a supported browser (`DBInstanceIdentifier`, `InstanceId`, `ClusterName`, `FunctionName`, `LoadBalancer`, `TargetGroup`), `g` jumps into that resource browser with the filter prefilled to the unhealthy resource (for ELB alarms the target group filter is prefilled too, so the drill-down lands on the alarmed target group), and `l` opens CloudWatch Logs prefilled with the derived log group (e.g. `/aws/lambda/`). diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 31fc0b9..f9571f9 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -109,6 +109,7 @@ Current repository clients include: - S3 - KMS - DynamoDB +- AWS Backup Pattern: @@ -263,6 +264,7 @@ Current screen families include: - KMS key list/detail and rotation-posture flows - EventBridge rule list/detail, scrollable complete event patterns, and type-to-confirm state changes for eligible rule modes - DynamoDB table list/detail and complete-primary-key `GetItem` flows +- AWS Backup vault list and scrollable recovery-point/protected-resource/failed-job detail flows - Inspector mode home, checklist setup, security findings/detail (including KMS rotation findings), and checklist results/detail flows - context picker, context add, and TUI-native context setup/export/unset flows - SSO account / role selection and exit notice flows diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index f6dd8b7..b862745 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -109,6 +109,7 @@ repository와 서비스별 AWS 연동 계층이다. - S3 - KMS - DynamoDB +- AWS Backup 패턴: @@ -263,6 +264,7 @@ UNIC은 현재 다섯 가지 인증 모드를 지원한다. - KMS key list/detail, rotation 상태 - EventBridge rule list/detail, 스크롤 가능한 전체 event pattern, 변경 가능한 rule mode의 type-to-confirm 상태 변경 flow - DynamoDB table list/detail, 전체 primary key 기반 `GetItem` +- AWS Backup vault list, recovery point/protected resource/failed job 스크롤 상세 화면 - Inspector mode home, checklist setup, KMS rotation finding을 포함한 security findings/detail, checklist results/detail - context picker, context add, TUI-native context setup/export/unset - SSO account / role selection, exit notice diff --git a/docs/project-overview.en.md b/docs/project-overview.en.md index 4f72ad0..785caac 100644 --- a/docs/project-overview.en.md +++ b/docs/project-overview.en.md @@ -29,10 +29,11 @@ Implemented service areas currently include: - S3 - Lambda - DynamoDB +- AWS Backup - KMS - Inspector mode -The application already includes interactive mutation flows, polling-based status flows, context helpers, and per-service drill-down screens. EC2 includes a first-class Auto Scaling Group browser for capacity, instance health, recent activity failures, and type-confirmed desired-capacity changes. CloudFormation includes failure-prioritized stack browsing, parameters, outputs, recent events with failure reasons, and polling-based drift detection. CloudWatch Metrics now includes resource-centric preset groups plus time-range, period, and statistic controls for faster terminal triage. EKS includes managed add-on status review, current-version upgrade readiness checks that compare control plane, managed node group, managed add-on version alignment, and EKS upgrade insights before a target upgrade is planned, plus a kubeconfig access helper that prepares copyable `aws eks update-kubeconfig` and `kubectl` handoff commands. ECR includes repository and image/tag browsing with cleanup-oriented untagged and stale image signals. FIS includes experiment template browsing with safe-run blast-radius preview, targets, actions, role ARN, stop condition summaries, and recent experiment history with status, timing, and failure/stop reasons. ACM includes an expiry-sorted certificate browser with validation, renewal, domain, and in-use details. KMS includes key browsing with aliases, state, manager, and automatic-rotation posture. Both browsers keep successfully loaded resources visible when an individual detail lookup fails and surface the failure inline; denied KMS rotation lookups are shown as unknown. ElastiCache includes replication-group and standalone-cluster browsing with node metadata and copyable endpoints. Step Functions includes state machine browsing and failure-first STANDARD execution triage with failed-state, error/cause, and input/output previews. EventBridge includes cross-bus rule browsing with complete scrollable event patterns, targets, best-effort seven-day CloudWatch trigger activity, and type-to-confirm enable/disable actions for eligible customer-managed rules; all-management-events rules remain read-only so their exact matching mode is preserved. DynamoDB includes table capacity, size, key, GSI, TTL, and stream inspection plus a single `GetItem` lookup by complete primary key; it has no scan path. +The application already includes interactive mutation flows, polling-based status flows, context helpers, and per-service drill-down screens. EC2 includes a first-class Auto Scaling Group browser for capacity, instance health, recent activity failures, and type-confirmed desired-capacity changes. CloudFormation includes failure-prioritized stack browsing, parameters, outputs, recent events with failure reasons, and polling-based drift detection. CloudWatch Metrics now includes resource-centric preset groups plus time-range, period, and statistic controls for faster terminal triage. EKS includes managed add-on status review, current-version upgrade readiness checks that compare control plane, managed node group, managed add-on version alignment, and EKS upgrade insights before a target upgrade is planned, plus a kubeconfig access helper that prepares copyable `aws eks update-kubeconfig` and `kubectl` handoff commands. ECR includes repository and image/tag browsing with cleanup-oriented untagged and stale image signals. FIS includes experiment template browsing with safe-run blast-radius preview, targets, actions, role ARN, stop condition summaries, and recent experiment history with status, timing, and failure/stop reasons. ACM includes an expiry-sorted certificate browser with validation, renewal, domain, and in-use details. KMS includes key browsing with aliases, state, manager, and automatic-rotation posture. Both browsers keep successfully loaded resources visible when an individual detail lookup fails and surface the failure inline; denied KMS rotation lookups are shown as unknown. ElastiCache includes replication-group and standalone-cluster browsing with node metadata and copyable endpoints. Step Functions includes state machine browsing and failure-first STANDARD execution triage with failed-state, error/cause, and input/output previews. EventBridge includes cross-bus rule browsing with complete scrollable event patterns, targets, best-effort seven-day CloudWatch trigger activity, and type-to-confirm enable/disable actions for eligible customer-managed rules; all-management-events rules remain read-only so their exact matching mode is preserved. DynamoDB includes table capacity, size, key, GSI, TTL, and stream inspection plus a single `GetItem` lookup by complete primary key; it has no scan path. AWS Backup includes read-only vault browsing with recovery points, protected resources, Vault Lock/encryption metadata, and recent failed or expired jobs; paginated partial results remain visible with inline warnings. Inspector mode now includes built-in security and cost/waste scans, including customer-managed KMS key rotation checks and ACM certificate expiry findings, plus checklist-driven readiness checks for RDS, security groups, secrets, Route53, VPCs/subnets, CloudWatch Logs, and baseline posture wrappers. The cost/waste pack surfaces unattached EIPs and EBS volumes, stopped EC2 instances, empty target groups, untagged EC2-family resources, and EBS snapshots aged 90 days or more. Per-resource lookup failures appear as warnings while findings from successful lookups remain available. When `inspector.required_tags` is configured, it additionally reports missing required keys on Elastic IPs, EBS volumes and snapshots, and EC2 instances. ## Primary User Flows diff --git a/docs/project-overview.ko.md b/docs/project-overview.ko.md index 04f7681..ebcfe12 100644 --- a/docs/project-overview.ko.md +++ b/docs/project-overview.ko.md @@ -29,10 +29,11 @@ UNIC은 다음 세 가지를 결합한 Go 기반 AWS 터미널 콘솔이다. - S3 - Lambda - DynamoDB +- AWS Backup - KMS - Inspector mode -애플리케이션은 이미 상호작용형 변경 작업 플로우, polling 기반 상태 확인, context helper, 서비스별 drill-down 화면을 포함한다. EC2에는 capacity, instance health, 최근 activity failure를 확인하고 type-to-confirm으로 desired capacity를 변경하는 Auto Scaling Group browser가 포함된다. CloudFormation은 실패/rollback 상태를 우선한 stack 목록, parameter, output, 실패 원인을 포함한 최근 event, polling 기반 drift detection을 제공한다. CloudWatch Metrics는 이제 resource-centric preset 그룹과 time-range / period / statistic control을 제공해 터미널에서 더 빠르게 triage할 수 있다. EKS는 cluster 화면에서 managed add-on 상태를 확인하고, target upgrade를 계획하기 전에 control plane, managed node group, managed add-on의 current-version alignment와 EKS upgrade insight를 함께 확인하는 upgrade readiness check와 복사 가능한 `aws eks update-kubeconfig` / `kubectl` handoff 명령을 준비하는 kubeconfig access helper를 포함한다. ECR은 repository와 image/tag 탐색을 제공하고, untagged image와 오래된 image를 cleanup 후보로 드러낸다. FIS는 experiment template 목록과 safe-run blast-radius preview, target, action, role ARN, stop condition 요약 상세 화면에 더해 최근 experiment history의 상태, 시간, failure/stop reason을 보여준다. ACM은 만료일 순서의 인증서 목록과 validation, renewal, domain, 사용 리소스 상세 정보를 제공한다. KMS는 alias, 상태, 관리 주체, 자동 rotation 상태를 포함한 key 탐색을 제공한다. 두 browser 모두 개별 detail lookup이 실패해도 성공적으로 불러온 resource를 유지하고 실패 내용을 inline으로 표시하며, 권한이 거부된 KMS rotation lookup은 unknown으로 표시한다. ElastiCache는 replication group과 standalone cluster 탐색, node metadata, endpoint 복사를 제공한다. Step Functions는 state machine 탐색과 실패 우선 STANDARD execution triage를 제공하며 failed state, error/cause, input/output preview를 보여준다. EventBridge는 전체 event bus의 rule과 스크롤 가능한 전체 event pattern, target, 최근 7일 CloudWatch 기반 best-effort trigger activity를 보여주고 변경 가능한 customer-managed rule의 enable/disable을 type-to-confirm으로 보호한다. all-management-events rule은 정확한 matching mode를 보존하기 위해 read-only로 유지한다. DynamoDB는 table capacity, size, key, GSI, TTL, stream 정보를 보여주고 전체 primary key를 입력받아 단일 `GetItem`만 수행하며 scan 경로는 제공하지 않는다. +애플리케이션은 이미 상호작용형 변경 작업 플로우, polling 기반 상태 확인, context helper, 서비스별 drill-down 화면을 포함한다. EC2에는 capacity, instance health, 최근 activity failure를 확인하고 type-to-confirm으로 desired capacity를 변경하는 Auto Scaling Group browser가 포함된다. CloudFormation은 실패/rollback 상태를 우선한 stack 목록, parameter, output, 실패 원인을 포함한 최근 event, polling 기반 drift detection을 제공한다. CloudWatch Metrics는 이제 resource-centric preset 그룹과 time-range / period / statistic control을 제공해 터미널에서 더 빠르게 triage할 수 있다. EKS는 cluster 화면에서 managed add-on 상태를 확인하고, target upgrade를 계획하기 전에 control plane, managed node group, managed add-on의 current-version alignment와 EKS upgrade insight를 함께 확인하는 upgrade readiness check와 복사 가능한 `aws eks update-kubeconfig` / `kubectl` handoff 명령을 준비하는 kubeconfig access helper를 포함한다. ECR은 repository와 image/tag 탐색을 제공하고, untagged image와 오래된 image를 cleanup 후보로 드러낸다. FIS는 experiment template 목록과 safe-run blast-radius preview, target, action, role ARN, stop condition 요약 상세 화면에 더해 최근 experiment history의 상태, 시간, failure/stop reason을 보여준다. ACM은 만료일 순서의 인증서 목록과 validation, renewal, domain, 사용 리소스 상세 정보를 제공한다. KMS는 alias, 상태, 관리 주체, 자동 rotation 상태를 포함한 key 탐색을 제공한다. 두 browser 모두 개별 detail lookup이 실패해도 성공적으로 불러온 resource를 유지하고 실패 내용을 inline으로 표시하며, 권한이 거부된 KMS rotation lookup은 unknown으로 표시한다. ElastiCache는 replication group과 standalone cluster 탐색, node metadata, endpoint 복사를 제공한다. Step Functions는 state machine 탐색과 실패 우선 STANDARD execution triage를 제공하며 failed state, error/cause, input/output preview를 보여준다. EventBridge는 전체 event bus의 rule과 스크롤 가능한 전체 event pattern, target, 최근 7일 CloudWatch 기반 best-effort trigger activity를 보여주고 변경 가능한 customer-managed rule의 enable/disable을 type-to-confirm으로 보호한다. all-management-events rule은 정확한 matching mode를 보존하기 위해 read-only로 유지한다. DynamoDB는 table capacity, size, key, GSI, TTL, stream 정보를 보여주고 전체 primary key를 입력받아 단일 `GetItem`만 수행하며 scan 경로는 제공하지 않는다. AWS Backup은 recovery point, protected resource, Vault Lock/encryption metadata, 최근 실패 또는 만료 job을 확인하는 read-only vault browser를 제공하며, pagination 일부가 실패해도 성공한 결과를 inline warning과 함께 유지한다. Inspector mode는 이제 customer-managed KMS key rotation 검사와 ACM 인증서 만료 finding을 포함한 built-in security 및 cost/waste scan과 함께 RDS, security group, secret, Route53, VPC/subnet, CloudWatch Logs, baseline posture wrapper를 다루는 checklist 기반 readiness check도 포함한다. cost/waste rule pack은 연결되지 않은 EIP와 EBS volume, 중지된 EC2 instance, 비어 있는 target group, 사용자 정의 tag가 없는 EC2 계열 resource, 90일 이상 된 EBS snapshot을 표시한다. 개별 resource lookup 실패는 warning으로 표시되며 성공한 lookup의 finding은 그대로 유지된다. `inspector.required_tags`가 설정되면 Elastic IP, EBS volume과 snapshot, EC2 instance에서 누락된 필수 tag key도 추가로 표시한다. ## 주요 사용자 흐름 diff --git a/go.mod b/go.mod index c47b26a..dbe67f3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/acm v1.44.1 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1 + github.com/aws/aws-sdk-go-v2/service/backup v1.60.2 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3 github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.9 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.56.0 diff --git a/go.sum b/go.sum index 5248b92..7312235 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aws/aws-sdk-go-v2/service/acm v1.44.1 h1:72rOAOGNHa3M+eCVb+alAQxhLeU8 github.com/aws/aws-sdk-go-v2/service/acm v1.44.1/go.mod h1:+vTOe3AOT1hL5xgO+JiD+LObbzNI5xku+VcNkn0Td3g= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1 h1:VB+9RFYfUUY8TyL6W025CZToo6h9vC3zeYob7M7/2CE= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1/go.mod h1:6q/I1pH386VpPfB6FE62X/MOs6NW/oCsY9FXU33YXOU= +github.com/aws/aws-sdk-go-v2/service/backup v1.60.2 h1:b3QwmC6vV20LLbUGedok6UlOe45HgnW7C93MThgk0P4= +github.com/aws/aws-sdk-go-v2/service/backup v1.60.2/go.mod h1:zMHhtRP9145uUJxHZM7yURy5/d/mn837hyVWBSz5RaM= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3 h1:FjNSXIPC9bbvVRh67j7jGf37gJo/5THzf+pS3T+Don0= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3/go.mod h1:yQcvrM5JfBihExrlz+2k7W6mBEM6xexhWT8eHr0akzs= github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.9 h1:ndjU1c3H1BYJy7fG0Y6Nh/sgD2M/KN+PZaqyCK/Fdfs= diff --git a/internal/app/app.go b/internal/app/app.go index ecf7e89..5c61de8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -126,6 +126,8 @@ const ( screenDynamoDBTableDetail screenDynamoDBLookupInput screenDynamoDBLookupResult + screenBackupVaultList + screenBackupVaultDetail screenBedrockKeyList screenBedrockKeyDetail screenBedrockKeyCreate @@ -219,6 +221,7 @@ type Model struct { eventBridge eventBridgeModel lambda lambdaModel dynamodb dynamoDBModel + backup backupModel inspector inspectorModel // Context picker @@ -351,6 +354,7 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. model.stepFunctions = newStepFunctionsModel() model.lambda = newLambdaModel() model.dynamodb = newDynamoDBModel() + model.backup = newBackupModel() model.inspector = newInspectorModel(configuredChecklistPath) model.applyServiceListFilter() return model @@ -965,6 +969,8 @@ func (m Model) startFeature(kind domain.FeatureKind) (tea.Model, tea.Cmd) { return m.lambda.Start(&m) case domain.FeatureDynamoDBBrowser: return m.dynamodb.Start(&m) + case domain.FeatureBackupBrowser: + return m.backup.Start(&m) case domain.FeatureBedrockAPIKeys: return m.bedrock.Start(&m) } diff --git a/internal/app/context_terminal.go b/internal/app/context_terminal.go index ac76a34..d481a29 100644 --- a/internal/app/context_terminal.go +++ b/internal/app/context_terminal.go @@ -53,6 +53,7 @@ func (m Model) selectedContextInfo() (config.ContextInfo, bool) { } func (m Model) beginContextSetup(selected config.ContextInfo) (tea.Model, tea.Cmd) { + normalizeBackupContextReturn(&m) if auth.IsBaseSSOContext(selected) { details := []string{ renderDetailLine("Context", selected.Name), @@ -69,6 +70,7 @@ func (m Model) beginContextSetup(selected config.ContextInfo) (tea.Model, tea.Cm } func (m Model) beginContextExport(selected config.ContextInfo) (tea.Model, tea.Cmd) { + normalizeBackupContextReturn(&m) return m.startLoadingWithMessage( "Copying environment exports...", []string{renderDetailLine("Context", selected.Name)}, @@ -77,6 +79,7 @@ func (m Model) beginContextExport(selected config.ContextInfo) (tea.Model, tea.C } func (m Model) beginContextUnset() (tea.Model, tea.Cmd) { + normalizeBackupContextReturn(&m) return m.startLoadingWithMessage( "Clearing terminal context...", []string{renderDetailLine("Action", "Unset shell exports and current context")}, diff --git a/internal/app/feature_submodel.go b/internal/app/feature_submodel.go index d8f8a4b..b44c7c8 100644 --- a/internal/app/feature_submodel.go +++ b/internal/app/feature_submodel.go @@ -13,5 +13,5 @@ type featureSubmodel interface { // such as service selection, context selection, SSM session launch, loading, and // errors remain root-owned unless a separate shell abstraction is introduced. func (m *Model) featureSubmodels() []featureSubmodel { - return []featureSubmodel{&m.ec2Browser, &m.autoScaling, &m.ecs, &m.eks, &m.ecr, &m.fis, &m.vpc, &m.reachability, &m.cwMetrics, &m.cwAlarms, &m.cloudTrail, &m.cwLogs, &m.rds, &m.cloudFormation, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.security, &m.s3, &m.sqs, &m.elb, &m.ssmParams, &m.elasticache, &m.kms, &m.acm, &m.stepFunctions, &m.eventBridge, &m.lambda, &m.dynamodb, &m.inspector} + return []featureSubmodel{&m.ec2Browser, &m.autoScaling, &m.ecs, &m.eks, &m.ecr, &m.fis, &m.vpc, &m.reachability, &m.cwMetrics, &m.cwAlarms, &m.cloudTrail, &m.cwLogs, &m.rds, &m.cloudFormation, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.security, &m.s3, &m.sqs, &m.elb, &m.ssmParams, &m.elasticache, &m.kms, &m.acm, &m.stepFunctions, &m.eventBridge, &m.lambda, &m.dynamodb, &m.backup, &m.inspector} } diff --git a/internal/app/filter.go b/internal/app/filter.go index a27a692..ab5b411 100644 --- a/internal/app/filter.go +++ b/internal/app/filter.go @@ -56,6 +56,7 @@ const ( filterStepFunctionStateMachines filterStepFunctionExecutions filterDynamoDBTables + filterBackupVaults ) // Filterable is implemented by any type that supports text-based filtering. diff --git a/internal/app/help.go b/internal/app/help.go index 348510f..830b694 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -878,6 +878,10 @@ func (m Model) helpScreenTitle() string { return "CloudFormation Stacks" case screenCloudFormationStackDetail: return "CloudFormation Stack Detail" + case screenBackupVaultList: + return "AWS Backup Vaults" + case screenBackupVaultDetail: + return "AWS Backup Recovery Detail" case screenElastiCacheNodeList: return "ElastiCache Nodes" case screenElastiCacheNodeDetail: diff --git a/internal/app/keymap.go b/internal/app/keymap.go index acf1f2a..bd08159 100644 --- a/internal/app/keymap.go +++ b/internal/app/keymap.go @@ -327,6 +327,21 @@ var screenKeymaps = map[screen][]keyBinding{ {keys: "q / esc", bar: "esc: detail", help: "Return to table detail"}, {bar: "H: home"}, }, + screenBackupVaultList: { + {keys: "↑/↓, j/k", bar: "↑/↓: navigate", help: "Move between backup vaults"}, + {keys: "/", bar: "/: filter", help: "Filter by vault name, state, type, region, or encryption key"}, + {keys: "r", bar: "r: refresh", help: "Refresh backup vaults"}, + {keys: "enter", bar: "enter: recovery", help: "Open recovery points, protected resources, and failed jobs"}, + {keys: "q / esc", bar: "esc: back", help: "Go back to the feature list"}, + {bar: "H: home"}, + }, + screenBackupVaultDetail: { + {keys: "↑/↓, j/k", bar: "↑/↓: scroll", help: "Scroll recovery-readiness details"}, + {keys: "pgup/pgdn", bar: "pgup/pgdn: page", help: "Scroll details by one page"}, + {keys: "r", bar: "r: refresh", help: "Refresh the selected backup vault"}, + {keys: "q / esc", bar: "esc: vaults", help: "Go back to the backup vault list"}, + {bar: "H: home"}, + }, screenIAMUserList: { {keys: "↑/↓, j/k", bar: "↑/↓: navigate", help: "Move between rows"}, diff --git a/internal/app/messages.go b/internal/app/messages.go index a5b1d02..8a5286a 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -487,3 +487,16 @@ type dynamoDBTableDetailLoadedMsg struct { type dynamoDBItemLoadedMsg struct { item *awsservice.DynamoDBItem } + +type backupVaultsLoadedMsg struct { + vaults []awsservice.BackupVault + warnings []error + err error +} + +type backupVaultDetailLoadedMsg struct { + vaultName string + detail *awsservice.BackupVaultDetail + warnings []error + err error +} diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go new file mode 100644 index 0000000..5806792 --- /dev/null +++ b/internal/app/screen_backup.go @@ -0,0 +1,452 @@ +package app + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +type backupModel struct { + vaults []awsservice.BackupVault + filtered []awsservice.BackupVault + idx int + selected *awsservice.BackupVault + detail *awsservice.BackupVaultDetail + detailScroll int + warnings []error + detailErrors []error + errorActive bool +} + +func newBackupModel() backupModel { return backupModel{} } + +func isBackupScreen(value screen) bool { + return value == screenBackupVaultList || value == screenBackupVaultDetail +} + +func resetBackupContextState(m *Model) { + m.backup = newBackupModel() + m.resetFilter(filterBackupVaults) +} + +func normalizeBackupContextReturn(m *Model) { + previous := &m.ctxPrevScreen + seen := make(map[screen]struct{}) + for range 8 { + current := *previous + if _, ok := seen[current]; ok { + return + } + seen[current] = struct{}{} + if isBackupScreen(current) || current == screenLoading && isBackupScreen(m.loadingReturnScreen) || current == screenError && m.backup.errorActive { + *previous = screenServiceList + return + } + previous = backupOverlayPrevious(m, current) + if previous == nil { + return + } + } +} + +func backupOverlayPrevious(m *Model, current screen) *screen { + switch current { + case screenSettings: + return &m.settingsPrevScreen + case screenCommandPalette: + return &m.palette.prevScreen + case screenViewList: + return &m.views.prevScreen + case screenContextPicker: + return &m.ctxPrevScreen + case screenRegionPicker: + return &m.regionPrevScreen + default: + return nil + } +} + +func (bm *backupModel) Start(m *Model) (tea.Model, tea.Cmd) { + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultList, "Loading AWS Backup vaults...", nil, bm.loadVaults(*m)) +} + +func (bm *backupModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case backupVaultsLoadedMsg: + if msg.err != nil { + bm.finishError(m, msg.err) + return *m, nil, true + } + bm.vaults = msg.vaults + bm.filtered = applyFilter(bm.vaults, m.filterValue(filterBackupVaults)) + bm.idx = 0 + bm.selected = nil + bm.detail = nil + bm.warnings = msg.warnings + bm.detailErrors = nil + bm.errorActive = false + finishBackupLoad(m, screenBackupVaultList) + return *m, nil, true + case backupVaultDetailLoadedMsg: + if bm.selected == nil || bm.selected.Name != msg.vaultName { + return *m, nil, true + } + if msg.err != nil { + bm.finishError(m, msg.err) + return *m, nil, true + } + bm.detail = msg.detail + bm.detailErrors = msg.warnings + bm.detailScroll = 0 + bm.errorActive = false + finishBackupLoad(m, screenBackupVaultDetail) + return *m, nil, true + } + return *m, nil, false +} + +func (bm *backupModel) finishError(m *Model, err error) { + bm.errorActive = true + m.errMsg = err.Error() + m.loadingTitle = "" + m.loadingDetails = nil + finishBackupLoad(m, screenError) +} + +func finishBackupLoad(m *Model, target screen) { + if !isBackupScreen(m.loadingReturnScreen) { + return + } + if m.ctxPickerPending && m.ctxPrevScreen == screenLoading { + m.ctxPrevScreen = target + } + if m.screen == screenLoading { + m.screen = target + m.loadingReturnScreen = 0 + return + } + current := m.screen + seen := make(map[screen]struct{}) + for range 8 { + if _, ok := seen[current]; ok { + return + } + seen[current] = struct{}{} + previous := backupOverlayPrevious(m, current) + if previous == nil { + return + } + if *previous == screenLoading { + *previous = target + m.loadingReturnScreen = 0 + return + } + current = *previous + } +} + +func (bm *backupModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch m.screen { + case screenBackupVaultList: + updated, cmd := bm.updateList(m, msg) + return updated, cmd, true + case screenBackupVaultDetail: + updated, cmd := bm.updateDetail(m, msg) + return updated, cmd, true + default: + return *m, nil, false + } +} + +func (bm backupModel) View(m Model) (string, bool) { + switch m.screen { + case screenBackupVaultList: + return bm.viewList(m), true + case screenBackupVaultDetail: + return bm.viewDetail(m), true + default: + return "", false + } +} + +func (bm *backupModel) ApplyFilter(m *Model, target filterTarget) bool { + if target != filterBackupVaults { + return false + } + bm.filtered = applyFilter(bm.vaults, m.filterValue(target)) + bm.idx = 0 + return true +} + +func (bm *backupModel) updateList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if cmd, handled := m.updateSharedFilter(msg, filterBackupVaults); handled { + return *m, cmd + } + switch msg.String() { + case "q", "esc": + m.resetFilter(filterBackupVaults) + m.screen = screenFeatureList + case "up", "k": + bm.idx = previousListIndex(bm.idx, len(bm.filtered)) + case "down", "j": + bm.idx = nextListIndex(bm.idx, len(bm.filtered)) + case "/": + return *m, m.activateFilter(filterBackupVaults) + case "r": + return bm.Start(m) + case "enter": + if bm.idx < len(bm.filtered) { + selected := bm.filtered[bm.idx] + bm.selected = &selected + bm.detail = nil + bm.detailErrors = nil + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultDetail, "Loading backup recovery posture...", []string{selected.Name}, bm.loadDetail(*m, selected)) + } + } + return *m, nil +} + +func (bm *backupModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + lines := bm.detailLines(*m) + visibleLines := max(m.height-8, 5) + maxOffset := max(len(lines)-visibleLines, 0) + switch msg.String() { + case "q", "esc": + bm.detailScroll = 0 + m.screen = screenBackupVaultList + case "up", "k": + bm.detailScroll = max(bm.detailScroll-1, 0) + case "down", "j": + bm.detailScroll = min(bm.detailScroll+1, maxOffset) + case "pgup": + bm.detailScroll = max(bm.detailScroll-visibleLines, 0) + case "pgdown": + bm.detailScroll = min(bm.detailScroll+visibleLines, maxOffset) + case "r": + if bm.selected != nil { + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultDetail, "Refreshing backup recovery posture...", []string{bm.selected.Name}, bm.loadDetail(*m, *bm.selected)) + } + } + return *m, nil +} + +func (bm backupModel) loadVaults(m Model) tea.Cmd { + return func() tea.Msg { + ctx := m.commandContext() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return backupVaultsLoadedMsg{err: err} + } + } + vaults, warnings, err := repo.ListBackupVaults(ctx) + return backupVaultsLoadedMsg{vaults: vaults, warnings: warnings, err: err} + } +} + +func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault) tea.Cmd { + return func() tea.Msg { + ctx := m.commandContext() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: err} + } + } + detail, warnings, err := repo.GetBackupVaultDetail(ctx, vault) + return backupVaultDetailLoadedMsg{vaultName: vault.Name, detail: detail, warnings: warnings, err: err} + } +} + +func (bm backupModel) viewList(m Model) string { + var b, panel strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("AWS Backup Vaults")) + b.WriteString("\n") + b.WriteString(m.renderFilterValue(filterBackupVaults)) + b.WriteString("\n") + warningLines := 0 + if len(bm.warnings) > 0 { + b.WriteString(m.renderWarningSummary(len(bm.warnings), "vault listing failures", bm.warnings[0].Error())) + warningLines = 2 + } + b.WriteString("\n") + + if len(bm.filtered) == 0 { + message := " No backup vaults found" + if len(bm.vaults) > 0 { + message = " No matching backup vaults" + } + panel.WriteString(dimStyle.Render(message)) + panel.WriteString("\n") + } else { + panel.WriteString(dimStyle.Render(" VAULT STATE POINTS LOCK TYPE")) + panel.WriteString("\n") + visibleLines := max(m.height-11-warningLines, 5) + start := max(bm.idx-visibleLines+1, 0) + width := max(m.width-m.currentListPanelStyle().GetHorizontalFrameSize()-2, 1) + for i := start; i < min(start+visibleLines, len(bm.filtered)); i++ { + cursor, style := " ", normalStyle + if i == bm.idx { + cursor, style = "> ", selectedStyle + } + row := truncateEC2DetailValue(escapeTerminalControls(bm.filtered[i].DisplayTitle()), width) + panel.WriteString(style.Render(cursor + m.renderHighlightedValue(filterBackupVaults, row))) + panel.WriteString("\n") + } + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d vaults", len(bm.filtered), len(bm.vaults)))) + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar(m.keymapHelpBar())) + return b.String() +} + +func (bm backupModel) viewDetail(m Model) string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + name := "" + if bm.selected != nil { + name = escapeTerminalControls(bm.selected.Name) + } + b.WriteString(titleStyle.Render("AWS Backup Recovery — " + name)) + b.WriteString("\n") + warningLines := 0 + if len(bm.detailErrors) > 0 { + b.WriteString(m.renderWarningSummary(len(bm.detailErrors), "detail lookup failures", bm.detailErrors[0].Error())) + warningLines = 2 + } + b.WriteString("\n") + + lines := bm.detailLines(m) + visibleLines := max(m.height-8-warningLines, 5) + start := min(bm.detailScroll, max(len(lines)-visibleLines, 0)) + end := min(start+visibleLines, len(lines)) + b.WriteString(m.renderListPanel(strings.Join(lines[start:end], "\n"))) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar(m.keymapHelpBar())) + return b.String() +} + +func (bm backupModel) detailLines(m Model) []string { + if bm.detail == nil { + return []string{dimStyle.Render(" No backup vault detail loaded")} + } + detail := bm.detail + vault := detail.Vault + lines := []string{ + backupDetailLine(m, "Vault", vault.Name), + backupDetailLine(m, "State", vault.State), + backupDetailLine(m, "Type", vault.Type), + backupDetailLine(m, "Region", vault.Region), + backupDetailLine(m, "Recovery Points", fmt.Sprintf("%d", vault.RecoveryPointCount)), + backupDetailLine(m, "Encryption Key", valueOrDashApp(vault.EncryptionKeyARN)), + backupDetailLine(m, "Key Type", valueOrDashApp(vault.EncryptionKeyType)), + backupDetailLine(m, "Vault Lock", backupLockSummary(vault)), + backupDetailLine(m, "ARN", vault.ARN), + "", + titleStyle.Render(fmt.Sprintf(" Recovery Points (%d)", len(detail.RecoveryPoints))), + } + if len(detail.RecoveryPoints) == 0 { + lines = append(lines, dimStyle.Render(" None available")) + } + for _, point := range detail.RecoveryPoints { + style := normalStyle + if point.NeedsAttention() { + style = warningStyle + } + lines = append(lines, + style.Render(" "+escapeTerminalControls(valueOrDashApp(point.ResourceName))), + backupDetailLine(m, "Type / Status", fmt.Sprintf("%s / %s", valueOrDashApp(point.ResourceType), valueOrDashApp(point.Status))), + backupDetailLine(m, "Resource ARN", point.ResourceARN), + backupDetailLine(m, "Created", backupTime(point.CreatedAt)), + backupDetailLine(m, "Completed", backupTime(point.CompletedAt)), + backupDetailLine(m, "Cold Storage", backupTime(point.MoveToColdAt)), + backupDetailLine(m, "Expires", backupTime(point.DeleteAt)), + backupDetailLine(m, "Size", formatBytes(point.SizeBytes)), + backupDetailLine(m, "Encrypted", fmt.Sprintf("%t", point.Encrypted)), + backupDetailLine(m, "Source Vault", valueOrDashApp(point.SourceVaultARN)), + backupDetailLine(m, "Recovery Point", point.ARN), + ) + if point.StatusMessage != "" { + lines = append(lines, backupDetailLine(m, "Reason", point.StatusMessage)) + } + } + + lines = append(lines, "", titleStyle.Render(fmt.Sprintf(" Protected Resources (%d)", len(detail.ProtectedResources)))) + if len(detail.ProtectedResources) == 0 { + lines = append(lines, dimStyle.Render(" None available")) + } + for _, resource := range detail.ProtectedResources { + lines = append(lines, + normalStyle.Render(" "+escapeTerminalControls(valueOrDashApp(resource.Name))), + backupDetailLine(m, "Type", resource.Type), + backupDetailLine(m, "Last Backup", backupTime(resource.LastBackupAt)), + backupDetailLine(m, "Resource ARN", resource.ARN), + backupDetailLine(m, "Recovery Point", resource.LastRecoveryPointARN), + ) + } + + lines = append(lines, "", titleStyle.Render(fmt.Sprintf(" Recent Failed / Expired Jobs (%d)", len(detail.FailedJobs)))) + if len(detail.FailedJobs) == 0 { + lines = append(lines, dimStyle.Render(" None in the AWS Backup 30-day job window")) + } + for _, job := range detail.FailedJobs { + lines = append(lines, + warningStyle.Render(" "+escapeTerminalControls(valueOrDashApp(job.ResourceName))), + backupDetailLine(m, "Type / State", fmt.Sprintf("%s / %s", valueOrDashApp(job.ResourceType), valueOrDashApp(job.State))), + backupDetailLine(m, "Created", backupTime(job.CreatedAt)), + backupDetailLine(m, "Completed", backupTime(job.CompletedAt)), + backupDetailLine(m, "Job ID", job.ID), + ) + if job.StatusMessage != "" { + lines = append(lines, backupDetailLine(m, "Reason", job.StatusMessage)) + } + } + return lines +} + +func backupDetailLine(m Model, label, value string) string { + return strings.TrimSuffix(m.renderEC2DetailLine(label, value), "\n") +} + +func backupTime(value time.Time) string { + if value.IsZero() { + return "-" + } + return value.Local().Format("2006-01-02 15:04 MST") +} + +func backupLockSummary(vault awsservice.BackupVault) string { + if !vault.Locked { + return "unlocked" + } + retention := "locked" + if vault.MinRetentionDays > 0 || vault.MaxRetentionDays > 0 { + retention += fmt.Sprintf(" (%d-%d days)", vault.MinRetentionDays, vault.MaxRetentionDays) + } + if !vault.LockDate.IsZero() { + retention += " lock date " + backupTime(vault.LockDate) + } + return retention +} + +func valueOrDashApp(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go new file mode 100644 index 0000000..d5eda70 --- /dev/null +++ b/internal/app/screen_backup_test.go @@ -0,0 +1,189 @@ +package app + +import ( + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +func backupTestVaults() []awsservice.BackupVault { + return []awsservice.BackupVault{ + {Name: "prod", ARN: "arn:vault:prod", Region: "ap-northeast-2", State: "AVAILABLE", Type: "BACKUP_VAULT", RecoveryPointCount: 3, Locked: true, EncryptionKeyARN: "arn:kms:prod"}, + {Name: "dev\x1b]52;c;spoof\a", ARN: "arn:vault:dev", Region: "ap-northeast-2", State: "FAILED", Type: "BACKUP_VAULT"}, + } +} + +func TestBackupHelpScreenTitles(t *testing.T) { + m := New(testConfig(), "", "dev") + for _, tc := range []struct { + screen screen + want string + }{ + {screenBackupVaultList, "AWS Backup Vaults"}, + {screenBackupVaultDetail, "AWS Backup Recovery Detail"}, + } { + m.screen = tc.screen + if got := m.helpScreenTitle(); got != tc.want { + t.Errorf("helpScreenTitle() = %q, want %q", got, tc.want) + } + } +} + +func TestBackupVaultListRendersFiltersWarningsAndEscapesControls(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + started, _ := m.backup.Start(&m) + m = started.(Model) + _, _, handled := m.backup.HandleMessage(&m, backupVaultsLoadedMsg{ + vaults: backupTestVaults(), warnings: []error{errors.New("second page denied")}, + }) + if !handled || m.screen != screenBackupVaultList { + t.Fatalf("expected backup vault list, screen=%v handled=%v", m.screen, handled) + } + view := m.backup.viewList(m) + plain := stripANSI(view) + for _, want := range []string{"AWS Backup Vaults", "prod", "AVAILABLE", "3", "vault listing failures"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in vault list, got:\n%s", want, plain) + } + } + if strings.Contains(view, "\x1b]52;c;spoof\a") || !strings.Contains(plain, `\x1b]52;c;spoof\a`) { + t.Fatalf("expected terminal controls to be escaped, got %q", view) + } + + m.storeFilterValue(filterBackupVaults, "failed") + m.applyFilterTarget(filterBackupVaults) + if len(m.backup.filtered) != 1 || !strings.Contains(m.backup.filtered[0].Name, "dev") { + t.Fatalf("expected one failed vault match, got %+v", m.backup.filtered) + } +} + +func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 100 + m.height = 10 + m.screen = screenBackupVaultList + m.backup.vaults = backupTestVaults()[:1] + m.backup.filtered = append([]awsservice.BackupVault(nil), m.backup.vaults...) + + updated, cmd := m.backup.updateList(&m, tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if cmd == nil || m.screen != screenLoading || m.backup.selected == nil || m.backup.selected.Name != "prod" { + t.Fatalf("expected detail load, screen=%v selected=%+v command=%v", m.screen, m.backup.selected, cmd) + } + now := time.Date(2026, 8, 26, 3, 0, 0, 0, time.UTC) + detail := &awsservice.BackupVaultDetail{ + Vault: *m.backup.selected, + RecoveryPoints: []awsservice.BackupRecoveryPoint{{ + ARN: "arn:point", ResourceName: "database\x1b[31m", ResourceType: "RDS", Status: "PARTIAL", StatusMessage: "access denied", CreatedAt: now, DeleteAt: now.Add(24 * time.Hour), SizeBytes: 2048, + }}, + ProtectedResources: []awsservice.BackupProtectedResource{{Name: "database", Type: "RDS", ARN: "arn:db", LastBackupAt: now, LastRecoveryPointARN: "arn:point"}}, + FailedJobs: []awsservice.BackupJob{{ID: "job-1", ResourceName: "database", ResourceType: "RDS", State: "FAILED", StatusMessage: "timeout", CreatedAt: now}}, + } + m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{vaultName: "prod", detail: detail, warnings: []error{errors.New("protected resources denied")}}) + if m.screen != screenBackupVaultDetail { + t.Fatalf("expected backup detail, got %v", m.screen) + } + initial := m.backup.viewDetail(m) + plain := stripANSI(initial) + for _, want := range []string{"AWS Backup Recovery", "detail lookup failures", "Recovery Points"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in initial detail, got:\n%s", want, plain) + } + } + if strings.Contains(initial, "\x1b[31m") { + t.Fatalf("expected recovery point controls to be escaped, got %q", initial) + } + + scrolled := "" + for range 10 { + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyPgDown}) + scrolled = stripANSI(m.backup.viewDetail(m)) + if strings.Contains(scrolled, "Protected Resources") || strings.Contains(scrolled, "Failed / Expired Jobs") { + break + } + } + if m.backup.detailScroll == 0 || (!strings.Contains(scrolled, "Protected Resources") && !strings.Contains(scrolled, "Failed / Expired Jobs")) { + t.Fatalf("expected page-down to reveal later recovery sections, scroll=%d view:\n%s", m.backup.detailScroll, scrolled) + } +} + +func TestBackupIgnoresStaleDetailLoads(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenLoading + m.loadingReturnScreen = screenBackupVaultDetail + m.backup.selected = &awsservice.BackupVault{Name: "current"} + _, _, handled := m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{ + vaultName: "stale", detail: &awsservice.BackupVaultDetail{Vault: awsservice.BackupVault{Name: "stale"}}, + }) + if !handled || m.screen != screenLoading || m.backup.detail != nil { + t.Fatalf("expected stale detail to be ignored, screen=%v detail=%+v handled=%v", m.screen, m.backup.detail, handled) + } +} + +func TestBackupLoadCompletionStaysBehindGlobalOverlays(t *testing.T) { + for _, tc := range []struct { + name string + screen screen + prepare func(*Model) + result func(Model) screen + }{ + {name: "settings", screen: screenSettings, prepare: func(m *Model) { m.settingsPrevScreen = screenLoading }, result: func(m Model) screen { return m.settingsPrevScreen }}, + {name: "palette", screen: screenCommandPalette, prepare: func(m *Model) { m.palette.prevScreen = screenLoading }, result: func(m Model) screen { return m.palette.prevScreen }}, + {name: "views", screen: screenViewList, prepare: func(m *Model) { m.views.prevScreen = screenLoading }, result: func(m Model) screen { return m.views.prevScreen }}, + {name: "region", screen: screenRegionPicker, prepare: func(m *Model) { m.regionPrevScreen = screenLoading }, result: func(m Model) screen { return m.regionPrevScreen }}, + } { + t.Run(tc.name, func(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = tc.screen + m.loadingReturnScreen = screenBackupVaultList + tc.prepare(&m) + m.backup.HandleMessage(&m, backupVaultsLoadedMsg{vaults: backupTestVaults()}) + if m.screen != tc.screen || tc.result(m) != screenBackupVaultList { + t.Fatalf("expected load completion behind %s, screen=%v return=%v", tc.name, m.screen, tc.result(m)) + } + }) + } +} + +func TestBackupErrorBehindContextPickerIsClearedByContextSwitch(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextPicker + m.ctxPrevScreen = screenLoading + m.loadingReturnScreen = screenBackupVaultList + m.backup.HandleMessage(&m, backupVaultsLoadedMsg{err: errors.New("backup denied")}) + if m.screen != screenContextPicker || m.ctxPrevScreen != screenError || !m.backup.errorActive { + t.Fatalf("expected error behind picker, screen=%v return=%v state=%+v", m.screen, m.ctxPrevScreen, m.backup) + } + + next := testConfig() + next.ContextName = "next" + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + if m.screen != screenServiceList || len(m.backup.vaults) != 0 || m.backup.errorActive { + t.Fatalf("expected context switch to clear backup state and return, screen=%v state=%+v", m.screen, m.backup) + } +} + +func TestBackupContextSwitchClearsStateAndFilter(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextPicker + m.ctxPrevScreen = screenBackupVaultDetail + m.backup.vaults = backupTestVaults() + m.backup.selected = &m.backup.vaults[0] + m.storeFilterValue(filterBackupVaults, "prod") + + next := &config.Config{ContextName: "next", Region: "us-east-1"} + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + if m.screen != screenServiceList || len(m.backup.vaults) != 0 || m.backup.selected != nil || m.filterValue(filterBackupVaults) != "" { + t.Fatalf("expected clean backup state after context switch, screen=%v state=%+v filter=%q", m.screen, m.backup, m.filterValue(filterBackupVaults)) + } +} diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index bf31d6f..781500e 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -84,6 +84,8 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } m.dynamodb = newDynamoDBModel() m.resetFilter(filterDynamoDBTables) + normalizeBackupContextReturn(&m) + resetBackupContextState(&m) m.ctxPrevWasLoading = false if m.settingsPrevScreen == screenLoading || isDynamoDBScreen(m.settingsPrevScreen) { m.settingsPrevScreen = screenServiceList @@ -113,6 +115,7 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.cfg.Region = msg.region m.awsRepo = msg.repo resetStepFunctionsContextState(&m) + resetBackupContextState(&m) m.ctxPrevWasLoading = false // Region-scoped feature state may contain resources from the previous // region, so return to the service catalog after switching. @@ -241,6 +244,7 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { normalizeStepFunctionsContextReturn(&m) } m.eventBridge.preserveOverlay(&m, screenFeatureList) + normalizeBackupContextReturn(&m) m.ctxPrevWasLoading = false return m.startLoading(m.switchContext(selected.Name)) } diff --git a/internal/app/screen_views.go b/internal/app/screen_views.go index 3417847..687e357 100644 --- a/internal/app/screen_views.go +++ b/internal/app/screen_views.go @@ -45,6 +45,7 @@ var featurePrimaryFilter = map[domain.FeatureKind]filterTarget{ domain.FeatureKMSKeyBrowser: filterKMSKeys, domain.FeatureLambdaBrowser: filterLambdaFunctions, domain.FeatureDynamoDBBrowser: filterDynamoDBTables, + domain.FeatureBackupBrowser: filterBackupVaults, domain.FeatureBedrockAPIKeys: filterBedrockKeys, domain.FeatureVPCBrowser: filterVPCs, } diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 792f1c5..c7adf6f 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -227,6 +227,15 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceBackup, + Features: []Feature{ + { + Kind: FeatureBackupBrowser, + Description: "Inspect backup vaults, recovery points, protected resources, and failed jobs", + }, + }, + }, { Name: ServiceBedrock, Features: []Feature{ diff --git a/internal/domain/catalog_test.go b/internal/domain/catalog_test.go index 4016b70..f1f5c1e 100644 --- a/internal/domain/catalog_test.go +++ b/internal/domain/catalog_test.go @@ -308,6 +308,22 @@ func TestCatalogContainsDynamoDBBrowserFeature(t *testing.T) { t.Error("DynamoDB service not found in catalog") } +func TestCatalogContainsBackupBrowserFeature(t *testing.T) { + for _, svc := range Catalog() { + if svc.Name != ServiceBackup { + continue + } + for _, feat := range svc.Features { + if feat.Kind == FeatureBackupBrowser { + return + } + } + t.Error("AWS Backup service should have Backup Recovery Browser feature") + return + } + t.Error("AWS Backup service not found in catalog") +} + func TestCatalogContainsEventBridgeRulesFeature(t *testing.T) { for _, svc := range Catalog() { if svc.Name != ServiceEventBridge { diff --git a/internal/domain/model.go b/internal/domain/model.go index 0105ac1..1d13fb5 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -32,6 +32,7 @@ const ( ServiceBedrock AwsService = "Bedrock" ServiceCloudFormation AwsService = "CloudFormation" ServiceDynamoDB AwsService = "DynamoDB" + ServiceBackup AwsService = "AWS Backup" ) // FeatureKind represents a specific feature within a service. @@ -72,6 +73,7 @@ const ( FeatureBedrockAPIKeys FeatureKind = "Bedrock API Keys" FeatureCloudFormationBrowser FeatureKind = "CloudFormation Stack Browser" FeatureDynamoDBBrowser FeatureKind = "DynamoDB Table Browser" + FeatureBackupBrowser FeatureKind = "Backup Recovery Browser" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go new file mode 100644 index 0000000..a8dbdaf --- /dev/null +++ b/internal/services/aws/backup.go @@ -0,0 +1,302 @@ +package aws + +import ( + "context" + "fmt" + "sort" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" + + uniclog "unic/internal/log" +) + +// ListBackupVaults returns vaults in the active region. Completed pages remain +// available when a later page fails. +func (r *AwsRepository) ListBackupVaults(ctx context.Context) ([]BackupVault, []error, error) { + uniclog.Debug("aws", "ListBackupVaults called") + + var vaults []BackupVault + var warnings []error + var nextToken *string + for { + out, err := r.BackupClient.ListBackupVaults(ctx, &backup.ListBackupVaultsInput{NextToken: nextToken}) + if err != nil { + wrapped := fmt.Errorf("failed to list AWS Backup vaults: %w", err) + if len(vaults) == 0 { + return nil, nil, wrapped + } + warnings = append(warnings, wrapped) + break + } + for _, vault := range out.BackupVaultList { + vaults = append(vaults, mapBackupVault(vault, r.Region)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + + sort.Slice(vaults, func(i, j int) bool { + left, right := normalizedSortKey(vaults[i].Name), normalizedSortKey(vaults[j].Name) + if left != right { + return left < right + } + return vaults[i].ARN < vaults[j].ARN + }) + return vaults, warnings, nil +} + +// GetBackupVaultDetail loads independent recovery point, protected resource, +// and recent failed-job sections. A denied section becomes a warning without +// hiding the others. +func (r *AwsRepository) GetBackupVaultDetail(ctx context.Context, vault BackupVault) (*BackupVaultDetail, []error, error) { + detail := &BackupVaultDetail{Vault: vault} + var warnings []error + + recoveryPoints, err := r.listBackupRecoveryPoints(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.RecoveryPoints = recoveryPoints + + resources, err := r.listBackupProtectedResources(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.ProtectedResources = resources + + jobs, err := r.listFailedBackupJobs(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.FailedJobs = jobs + + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return detail, warnings, nil +} + +func (r *AwsRepository) listBackupRecoveryPoints(ctx context.Context, vaultName string) ([]BackupRecoveryPoint, error) { + var items []BackupRecoveryPoint + var nextToken *string + for { + out, err := r.BackupClient.ListRecoveryPointsByBackupVault(ctx, &backup.ListRecoveryPointsByBackupVaultInput{ + BackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupRecoveryPoints(items) + return items, fmt.Errorf("failed to list recovery points for backup vault %s: %w", vaultName, err) + } + for _, item := range out.RecoveryPoints { + items = append(items, mapBackupRecoveryPoint(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupRecoveryPoints(items) + return items, nil +} + +func (r *AwsRepository) listBackupProtectedResources(ctx context.Context, vaultName string) ([]BackupProtectedResource, error) { + var items []BackupProtectedResource + var nextToken *string + for { + out, err := r.BackupClient.ListProtectedResourcesByBackupVault(ctx, &backup.ListProtectedResourcesByBackupVaultInput{ + BackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupProtectedResources(items) + return items, fmt.Errorf("failed to list protected resources for backup vault %s: %w", vaultName, err) + } + for _, item := range out.Results { + items = append(items, mapBackupProtectedResource(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupProtectedResources(items) + return items, nil +} + +func (r *AwsRepository) listFailedBackupJobs(ctx context.Context, vaultName string) ([]BackupJob, error) { + var items []BackupJob + var nextToken *string + for { + out, err := r.BackupClient.ListBackupJobs(ctx, &backup.ListBackupJobsInput{ + ByBackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupJobs(items) + return items, fmt.Errorf("failed to list recent jobs for backup vault %s: %w", vaultName, err) + } + for _, item := range out.BackupJobs { + if !backupJobNeedsAttention(string(item.State), awssdk.ToString(item.MessageCategory)) { + continue + } + items = append(items, mapBackupJob(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupJobs(items) + return items, nil +} + +func sortBackupProtectedResources(items []BackupProtectedResource) { + sort.Slice(items, func(i, j int) bool { + if !items[i].LastBackupAt.Equal(items[j].LastBackupAt) { + return items[i].LastBackupAt.After(items[j].LastBackupAt) + } + return normalizedSortKey(items[i].Name) < normalizedSortKey(items[j].Name) + }) +} + +func sortBackupJobs(items []BackupJob) { + sort.SliceStable(items, func(i, j int) bool { + left, right := backupJobStatusRank(items[i].State), backupJobStatusRank(items[j].State) + if left != right { + return left < right + } + return items[i].CreatedAt.After(items[j].CreatedAt) + }) +} + +func mapBackupVault(item backuptypes.BackupVaultListMember, region string) BackupVault { + vault := BackupVault{ + ARN: awssdk.ToString(item.BackupVaultArn), + Name: awssdk.ToString(item.BackupVaultName), + Region: region, + State: string(item.VaultState), + Type: string(item.VaultType), + EncryptionKeyARN: awssdk.ToString(item.EncryptionKeyArn), + EncryptionKeyType: string(item.EncryptionKeyType), + RecoveryPointCount: item.NumberOfRecoveryPoints, + Locked: awssdk.ToBool(item.Locked), + MinRetentionDays: awssdk.ToInt64(item.MinRetentionDays), + MaxRetentionDays: awssdk.ToInt64(item.MaxRetentionDays), + } + if item.CreationDate != nil { + vault.CreatedAt = *item.CreationDate + } + if item.LockDate != nil { + vault.LockDate = *item.LockDate + } + return vault +} + +func mapBackupRecoveryPoint(item backuptypes.RecoveryPointByBackupVault) BackupRecoveryPoint { + mapped := BackupRecoveryPoint{ + ARN: awssdk.ToString(item.RecoveryPointArn), + ResourceARN: awssdk.ToString(item.ResourceArn), + ResourceName: awssdk.ToString(item.ResourceName), + ResourceType: awssdk.ToString(item.ResourceType), + Status: string(item.Status), + StatusMessage: awssdk.ToString(item.StatusMessage), + SourceVaultARN: awssdk.ToString(item.SourceBackupVaultArn), + SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + Encrypted: item.IsEncrypted, + } + if item.CreationDate != nil { + mapped.CreatedAt = *item.CreationDate + } + if item.CompletionDate != nil { + mapped.CompletedAt = *item.CompletionDate + } + if item.CalculatedLifecycle != nil { + if item.CalculatedLifecycle.MoveToColdStorageAt != nil { + mapped.MoveToColdAt = *item.CalculatedLifecycle.MoveToColdStorageAt + } + if item.CalculatedLifecycle.DeleteAt != nil { + mapped.DeleteAt = *item.CalculatedLifecycle.DeleteAt + } + } + return mapped +} + +func mapBackupProtectedResource(item backuptypes.ProtectedResource) BackupProtectedResource { + mapped := BackupProtectedResource{ + ARN: awssdk.ToString(item.ResourceArn), + Name: awssdk.ToString(item.ResourceName), + Type: awssdk.ToString(item.ResourceType), + LastRecoveryPointARN: awssdk.ToString(item.LastRecoveryPointArn), + } + if item.LastBackupTime != nil { + mapped.LastBackupAt = *item.LastBackupTime + } + return mapped +} + +func mapBackupJob(item backuptypes.BackupJob) BackupJob { + mapped := BackupJob{ + ID: awssdk.ToString(item.BackupJobId), + ResourceARN: awssdk.ToString(item.ResourceArn), + ResourceName: awssdk.ToString(item.ResourceName), + ResourceType: awssdk.ToString(item.ResourceType), + State: string(item.State), + StatusMessage: awssdk.ToString(item.StatusMessage), + SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + } + if item.CreationDate != nil { + mapped.CreatedAt = *item.CreationDate + } + if item.CompletionDate != nil { + mapped.CompletedAt = *item.CompletionDate + } + return mapped +} + +func sortBackupRecoveryPoints(items []BackupRecoveryPoint) { + sort.SliceStable(items, func(i, j int) bool { + if items[i].NeedsAttention() != items[j].NeedsAttention() { + return items[i].NeedsAttention() + } + if !items[i].DeleteAt.Equal(items[j].DeleteAt) { + if items[i].DeleteAt.IsZero() { + return false + } + if items[j].DeleteAt.IsZero() { + return true + } + return items[i].DeleteAt.Before(items[j].DeleteAt) + } + if !items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].CreatedAt.After(items[j].CreatedAt) + } + return normalizedSortKey(items[i].ResourceName) < normalizedSortKey(items[j].ResourceName) + }) +} + +func backupJobNeedsAttention(state, category string) bool { + switch strings.ToUpper(state) { + case "FAILED", "ABORTED", "EXPIRED", "PARTIAL": + return true + case "COMPLETED": + return category != "" && !strings.EqualFold(category, "SUCCESS") + default: + return false + } +} + +func backupJobStatusRank(state string) int { + switch strings.ToUpper(state) { + case "FAILED": + return 0 + case "EXPIRED": + return 1 + case "ABORTED", "PARTIAL": + return 2 + default: + return 3 + } +} diff --git a/internal/services/aws/backup_model.go b/internal/services/aws/backup_model.go new file mode 100644 index 0000000..7ecc5c1 --- /dev/null +++ b/internal/services/aws/backup_model.go @@ -0,0 +1,95 @@ +package aws + +import ( + "fmt" + "strings" + "time" +) + +// BackupVault contains vault metadata rendered by the browser. +type BackupVault struct { + ARN string + Name string + Region string + State string + Type string + EncryptionKeyARN string + EncryptionKeyType string + RecoveryPointCount int64 + Locked bool + LockDate time.Time + MinRetentionDays int64 + MaxRetentionDays int64 + CreatedAt time.Time +} + +// FilterText returns searchable vault metadata. +func (v BackupVault) FilterText() string { + return strings.ToLower(strings.Join([]string{v.ARN, v.Name, v.Region, v.State, v.Type, v.EncryptionKeyARN, v.EncryptionKeyType}, " ")) +} + +// DisplayTitle returns a column-aligned vault row. +func (v BackupVault) DisplayTitle() string { + lock := "-" + if v.Locked { + lock = "locked" + } + return fmt.Sprintf("%-32.32s %-12.12s %8d %-8s %s", v.Name, valueOrDash(v.State), v.RecoveryPointCount, lock, valueOrDash(v.Type)) +} + +// BackupVaultDetail contains the recovery-readiness signals for one vault. +type BackupVaultDetail struct { + Vault BackupVault + RecoveryPoints []BackupRecoveryPoint + ProtectedResources []BackupProtectedResource + FailedJobs []BackupJob +} + +// BackupRecoveryPoint contains recovery point metadata used by the detail screen. +type BackupRecoveryPoint struct { + ARN string + ResourceARN string + ResourceName string + ResourceType string + Status string + StatusMessage string + SourceVaultARN string + SizeBytes int64 + Encrypted bool + CreatedAt time.Time + CompletedAt time.Time + MoveToColdAt time.Time + DeleteAt time.Time +} + +// NeedsAttention reports whether the recovery point is not currently usable. +func (r BackupRecoveryPoint) NeedsAttention() bool { + switch strings.ToUpper(r.Status) { + case "AVAILABLE", "COMPLETED": + return false + default: + return true + } +} + +// BackupProtectedResource contains the latest backup metadata for one resource. +type BackupProtectedResource struct { + ARN string + Name string + Type string + LastBackupAt time.Time + LastRecoveryPointARN string +} + +// BackupJob contains a recent failed or expired backup job. +type BackupJob struct { + ID string + ResourceARN string + ResourceName string + ResourceType string + State string + StatusMessage string + SizeBytes int64 + CreatedAt time.Time + CompletedAt time.Time +} diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go new file mode 100644 index 0000000..3cfdc6b --- /dev/null +++ b/internal/services/aws/backup_test.go @@ -0,0 +1,169 @@ +package aws + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" +) + +type mockBackupClient struct { + listVaults func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) + listPoints func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) + listResources func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) + listJobs func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) +} + +func (m *mockBackupClient) ListBackupVaults(ctx context.Context, input *backup.ListBackupVaultsInput, opts ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return m.listVaults(ctx, input, opts...) +} + +func (m *mockBackupClient) ListRecoveryPointsByBackupVault(ctx context.Context, input *backup.ListRecoveryPointsByBackupVaultInput, opts ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return m.listPoints(ctx, input, opts...) +} + +func (m *mockBackupClient) ListProtectedResourcesByBackupVault(ctx context.Context, input *backup.ListProtectedResourcesByBackupVaultInput, opts ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return m.listResources(ctx, input, opts...) +} + +func (m *mockBackupClient) ListBackupJobs(ctx context.Context, input *backup.ListBackupJobsInput, opts ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return m.listJobs(ctx, input, opts...) +} + +func TestListBackupVaultsPaginatesMapsAndPreservesCompletedPages(t *testing.T) { + now := time.Date(2026, 8, 26, 1, 0, 0, 0, time.UTC) + calls := 0 + client := &mockBackupClient{listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + calls++ + if calls == 1 { + if input.NextToken != nil { + t.Fatalf("expected no first-page token, got %q", awssdk.ToString(input.NextToken)) + } + return &backup.ListBackupVaultsOutput{ + NextToken: awssdk.String("page-2"), + BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("prod"), BackupVaultArn: awssdk.String("arn:prod"), + CreationDate: awssdk.Time(now), EncryptionKeyArn: awssdk.String("arn:kms"), + EncryptionKeyType: backuptypes.EncryptionKeyTypeCustomerManagedKmsKey, + Locked: awssdk.Bool(true), LockDate: awssdk.Time(now.Add(time.Hour)), + MinRetentionDays: awssdk.Int64(7), MaxRetentionDays: awssdk.Int64(365), + NumberOfRecoveryPoints: 4, VaultState: backuptypes.VaultStateAvailable, + VaultType: backuptypes.VaultTypeBackupVault, + }}, + }, nil + } + if awssdk.ToString(input.NextToken) != "page-2" { + t.Fatalf("expected second-page token, got %q", awssdk.ToString(input.NextToken)) + } + return nil, errors.New("page denied") + }} + + vaults, warnings, err := (&AwsRepository{BackupClient: client, Region: "ap-northeast-2"}).ListBackupVaults(context.Background()) + if err != nil { + t.Fatal(err) + } + if calls != 2 || len(vaults) != 1 || len(warnings) != 1 { + t.Fatalf("expected one retained vault and one warning, calls=%d vaults=%+v warnings=%v", calls, vaults, warnings) + } + vault := vaults[0] + if vault.Name != "prod" || vault.Region != "ap-northeast-2" || !vault.Locked || vault.RecoveryPointCount != 4 || vault.EncryptionKeyARN != "arn:kms" { + t.Fatalf("unexpected vault mapping: %+v", vault) + } + for _, value := range []string{"prod", "available", "arn:kms", "ap-northeast-2"} { + if !strings.Contains(vault.FilterText(), value) { + t.Fatalf("expected filter text to contain %q: %q", value, vault.FilterText()) + } + } +} + +func TestListBackupVaultsReturnsFatalInitialError(t *testing.T) { + client := &mockBackupClient{listVaults: func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return nil, errors.New("denied") + }} + _, _, err := (&AwsRepository{BackupClient: client}).ListBackupVaults(context.Background()) + if err == nil || !strings.Contains(err.Error(), "list AWS Backup vaults") { + t.Fatalf("expected contextual fatal list error, got %v", err) + } +} + +func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testing.T) { + now := time.Date(2026, 8, 26, 2, 0, 0, 0, time.UTC) + pointCalls := 0 + client := &mockBackupClient{ + listPoints: func(_ context.Context, input *backup.ListRecoveryPointsByBackupVaultInput, _ ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + pointCalls++ + if awssdk.ToString(input.BackupVaultName) != "prod" { + t.Fatalf("expected prod vault, got %q", awssdk.ToString(input.BackupVaultName)) + } + if pointCalls == 1 { + return &backup.ListRecoveryPointsByBackupVaultOutput{ + NextToken: awssdk.String("page-2"), + RecoveryPoints: []backuptypes.RecoveryPointByBackupVault{ + {RecoveryPointArn: awssdk.String("arn:healthy"), ResourceName: awssdk.String("healthy"), ResourceType: awssdk.String("RDS"), Status: backuptypes.RecoveryPointStatusCompleted, CreationDate: awssdk.Time(now.Add(-time.Hour))}, + {RecoveryPointArn: awssdk.String("arn:failed"), ResourceName: awssdk.String("failed"), ResourceType: awssdk.String("EBS"), Status: backuptypes.RecoveryPointStatusPartial, StatusMessage: awssdk.String("snapshot failed"), CreationDate: awssdk.Time(now), CalculatedLifecycle: &backuptypes.CalculatedLifecycle{DeleteAt: awssdk.Time(now.Add(24 * time.Hour))}}, + }, + }, nil + } + return nil, errors.New("second page denied") + }, + listResources: func(_ context.Context, input *backup.ListProtectedResourcesByBackupVaultInput, _ ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + if awssdk.ToString(input.BackupVaultName) != "prod" { + t.Fatalf("expected prod vault, got %q", awssdk.ToString(input.BackupVaultName)) + } + return &backup.ListProtectedResourcesByBackupVaultOutput{Results: []backuptypes.ProtectedResource{{ + ResourceArn: awssdk.String("arn:resource"), ResourceName: awssdk.String("database"), ResourceType: awssdk.String("RDS"), LastBackupTime: awssdk.Time(now), LastRecoveryPointArn: awssdk.String("arn:healthy"), + }}}, nil + }, + listJobs: func(_ context.Context, input *backup.ListBackupJobsInput, _ ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + if awssdk.ToString(input.ByBackupVaultName) != "prod" { + t.Fatalf("expected prod vault filter, got %q", awssdk.ToString(input.ByBackupVaultName)) + } + return &backup.ListBackupJobsOutput{BackupJobs: []backuptypes.BackupJob{ + {BackupJobId: awssdk.String("ok"), State: backuptypes.BackupJobStateCompleted, MessageCategory: awssdk.String("SUCCESS")}, + {BackupJobId: awssdk.String("expired"), ResourceName: awssdk.String("old"), State: backuptypes.BackupJobStateExpired, CreationDate: awssdk.Time(now.Add(-time.Hour))}, + {BackupJobId: awssdk.String("failed"), ResourceName: awssdk.String("db"), State: backuptypes.BackupJobStateFailed, StatusMessage: awssdk.String("access denied"), CreationDate: awssdk.Time(now)}, + }}, nil + }, + } + + vault := BackupVault{Name: "prod", ARN: "arn:vault"} + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(context.Background(), vault) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0].Error(), "second page denied") { + t.Fatalf("expected retained-page warning, got %v", warnings) + } + if len(detail.RecoveryPoints) != 2 || detail.RecoveryPoints[0].ARN != "arn:failed" { + t.Fatalf("expected failed recovery point first after partial pagination, got %+v", detail.RecoveryPoints) + } + if len(detail.ProtectedResources) != 1 || detail.ProtectedResources[0].LastRecoveryPointARN != "arn:healthy" { + t.Fatalf("unexpected protected resources: %+v", detail.ProtectedResources) + } + if len(detail.FailedJobs) != 2 || detail.FailedJobs[0].ID != "failed" || detail.FailedJobs[1].ID != "expired" { + t.Fatalf("expected only failure jobs in priority order, got %+v", detail.FailedJobs) + } +} + +func TestGetBackupVaultDetailReturnsWarningsWhenAllSectionsAreDenied(t *testing.T) { + client := &mockBackupClient{ + listPoints: func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return nil, errors.New("points denied") + }, + listResources: func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return nil, errors.New("resources denied") + }, + listJobs: func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return nil, errors.New("jobs denied") + }, + } + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(context.Background(), BackupVault{Name: "prod"}) + if err != nil || detail == nil || len(warnings) != 3 { + t.Fatalf("expected empty detail with three warnings, detail=%+v warnings=%v err=%v", detail, warnings, err) + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index ce3d64b..bbbf1ff 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/aws/aws-sdk-go-v2/service/autoscaling" + "github.com/aws/aws-sdk-go-v2/service/backup" "github.com/aws/aws-sdk-go-v2/service/cloudformation" "github.com/aws/aws-sdk-go-v2/service/cloudtrail" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" @@ -47,6 +48,7 @@ var _ KMSClientAPI = (*kms.Client)(nil) var _ ACMClientAPI = (*acm.Client)(nil) var _ StepFunctionsClientAPI = (*sfn.Client)(nil) var _ DynamoDBClientAPI = (*dynamodb.Client)(nil) +var _ BackupClientAPI = (*backup.Client)(nil) // Verify *ec2.Client satisfies EC2ClientAPI at compile time. var _ EC2ClientAPI = (*ec2.Client)(nil) @@ -163,6 +165,14 @@ type DynamoDBClientAPI interface { GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) } +// BackupClientAPI is the interface for read-only AWS Backup browser operations. +type BackupClientAPI interface { + ListBackupVaults(ctx context.Context, params *backup.ListBackupVaultsInput, optFns ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) + ListRecoveryPointsByBackupVault(ctx context.Context, params *backup.ListRecoveryPointsByBackupVaultInput, optFns ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) + ListProtectedResourcesByBackupVault(ctx context.Context, params *backup.ListProtectedResourcesByBackupVaultInput, optFns ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) + ListBackupJobs(ctx context.Context, params *backup.ListBackupJobsInput, optFns ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) +} + // RDSClientAPI is the interface for RDS operations used by AwsRepository. type RDSClientAPI interface { DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) @@ -414,6 +424,7 @@ type AwsRepository struct { ACMClient ACMClientAPI StepFunctionsClient StepFunctionsClientAPI DynamoDBClient DynamoDBClientAPI + BackupClient BackupClientAPI Region string Profile string awsCfg aws.Config @@ -540,6 +551,7 @@ func newRepositoryFromConfig(awsCfg aws.Config, region, profile string) *AwsRepo ACMClient: acm.NewFromConfig(awsCfg), StepFunctionsClient: sfn.NewFromConfig(awsCfg), DynamoDBClient: dynamodb.NewFromConfig(awsCfg), + BackupClient: backup.NewFromConfig(awsCfg), Region: region, Profile: profile, awsCfg: awsCfg, From 9312bb2fac3eb1a59fd6a6879998d39e529ce187 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 01:55:05 +0900 Subject: [PATCH 2/8] fix: preserve optional Backup metadata - render one-sided Vault Lock retention bounds accurately - distinguish unknown recovery-point sizes from explicit zero values --- internal/app/screen_backup.go | 16 +++++++++++++-- internal/app/screen_backup_test.go | 27 ++++++++++++++++++++++++- internal/services/aws/backup.go | 3 +++ internal/services/aws/backup_model.go | 3 +++ internal/services/aws/backup_test.go | 29 +++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 5806792..22b9e5d 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -376,7 +376,7 @@ func (bm backupModel) detailLines(m Model) []string { backupDetailLine(m, "Completed", backupTime(point.CompletedAt)), backupDetailLine(m, "Cold Storage", backupTime(point.MoveToColdAt)), backupDetailLine(m, "Expires", backupTime(point.DeleteAt)), - backupDetailLine(m, "Size", formatBytes(point.SizeBytes)), + backupDetailLine(m, "Size", backupRecoveryPointSize(point)), backupDetailLine(m, "Encrypted", fmt.Sprintf("%t", point.Encrypted)), backupDetailLine(m, "Source Vault", valueOrDashApp(point.SourceVaultARN)), backupDetailLine(m, "Recovery Point", point.ARN), @@ -435,8 +435,13 @@ func backupLockSummary(vault awsservice.BackupVault) string { return "unlocked" } retention := "locked" - if vault.MinRetentionDays > 0 || vault.MaxRetentionDays > 0 { + switch { + case vault.MinRetentionKnown && vault.MaxRetentionKnown: retention += fmt.Sprintf(" (%d-%d days)", vault.MinRetentionDays, vault.MaxRetentionDays) + case vault.MinRetentionKnown: + retention += fmt.Sprintf(" (minimum %d days)", vault.MinRetentionDays) + case vault.MaxRetentionKnown: + retention += fmt.Sprintf(" (maximum %d days)", vault.MaxRetentionDays) } if !vault.LockDate.IsZero() { retention += " lock date " + backupTime(vault.LockDate) @@ -444,6 +449,13 @@ func backupLockSummary(vault awsservice.BackupVault) string { return retention } +func backupRecoveryPointSize(point awsservice.BackupRecoveryPoint) string { + if !point.SizeBytesKnown { + return "-" + } + return formatBytes(point.SizeBytes) +} + func valueOrDashApp(value string) string { if value == "" { return "-" diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index d5eda70..06c60e3 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -82,7 +82,7 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { detail := &awsservice.BackupVaultDetail{ Vault: *m.backup.selected, RecoveryPoints: []awsservice.BackupRecoveryPoint{{ - ARN: "arn:point", ResourceName: "database\x1b[31m", ResourceType: "RDS", Status: "PARTIAL", StatusMessage: "access denied", CreatedAt: now, DeleteAt: now.Add(24 * time.Hour), SizeBytes: 2048, + ARN: "arn:point", ResourceName: "database\x1b[31m", ResourceType: "RDS", Status: "PARTIAL", StatusMessage: "access denied", CreatedAt: now, DeleteAt: now.Add(24 * time.Hour), SizeBytes: 2048, SizeBytesKnown: true, }}, ProtectedResources: []awsservice.BackupProtectedResource{{Name: "database", Type: "RDS", ARN: "arn:db", LastBackupAt: now, LastRecoveryPointARN: "arn:point"}}, FailedJobs: []awsservice.BackupJob{{ID: "job-1", ResourceName: "database", ResourceType: "RDS", State: "FAILED", StatusMessage: "timeout", CreatedAt: now}}, @@ -115,6 +115,31 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { } } +func TestBackupOptionalNumericFieldsRenderWithoutFalseZeroes(t *testing.T) { + for _, tc := range []struct { + name string + vault awsservice.BackupVault + want string + }{ + {name: "minimum only", vault: awsservice.BackupVault{Locked: true, MinRetentionDays: 7, MinRetentionKnown: true}, want: "locked (minimum 7 days)"}, + {name: "maximum only", vault: awsservice.BackupVault{Locked: true, MaxRetentionDays: 365, MaxRetentionKnown: true}, want: "locked (maximum 365 days)"}, + {name: "unbounded", vault: awsservice.BackupVault{Locked: true}, want: "locked"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := backupLockSummary(tc.vault); got != tc.want { + t.Fatalf("backupLockSummary() = %q, want %q", got, tc.want) + } + }) + } + + if got := backupRecoveryPointSize(awsservice.BackupRecoveryPoint{}); got != "-" { + t.Fatalf("unknown recovery-point size = %q, want -", got) + } + if got := backupRecoveryPointSize(awsservice.BackupRecoveryPoint{SizeBytesKnown: true}); got != "0 B" { + t.Fatalf("known zero recovery-point size = %q, want 0 B", got) + } +} + func TestBackupIgnoresStaleDetailLoads(t *testing.T) { m := New(testConfig(), "", "dev") m.screen = screenLoading diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go index a8dbdaf..6ee420e 100644 --- a/internal/services/aws/backup.go +++ b/internal/services/aws/backup.go @@ -184,7 +184,9 @@ func mapBackupVault(item backuptypes.BackupVaultListMember, region string) Backu RecoveryPointCount: item.NumberOfRecoveryPoints, Locked: awssdk.ToBool(item.Locked), MinRetentionDays: awssdk.ToInt64(item.MinRetentionDays), + MinRetentionKnown: item.MinRetentionDays != nil, MaxRetentionDays: awssdk.ToInt64(item.MaxRetentionDays), + MaxRetentionKnown: item.MaxRetentionDays != nil, } if item.CreationDate != nil { vault.CreatedAt = *item.CreationDate @@ -205,6 +207,7 @@ func mapBackupRecoveryPoint(item backuptypes.RecoveryPointByBackupVault) BackupR StatusMessage: awssdk.ToString(item.StatusMessage), SourceVaultARN: awssdk.ToString(item.SourceBackupVaultArn), SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + SizeBytesKnown: item.BackupSizeInBytes != nil, Encrypted: item.IsEncrypted, } if item.CreationDate != nil { diff --git a/internal/services/aws/backup_model.go b/internal/services/aws/backup_model.go index 7ecc5c1..2d81fde 100644 --- a/internal/services/aws/backup_model.go +++ b/internal/services/aws/backup_model.go @@ -19,7 +19,9 @@ type BackupVault struct { Locked bool LockDate time.Time MinRetentionDays int64 + MinRetentionKnown bool MaxRetentionDays int64 + MaxRetentionKnown bool CreatedAt time.Time } @@ -55,6 +57,7 @@ type BackupRecoveryPoint struct { StatusMessage string SourceVaultARN string SizeBytes int64 + SizeBytesKnown bool Encrypted bool CreatedAt time.Time CompletedAt time.Time diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go index 3cfdc6b..d8c5b3e 100644 --- a/internal/services/aws/backup_test.go +++ b/internal/services/aws/backup_test.go @@ -91,6 +91,35 @@ func TestListBackupVaultsReturnsFatalInitialError(t *testing.T) { } } +func TestBackupOptionalNumericFieldsPreservePresence(t *testing.T) { + for _, tc := range []struct { + name string + min, max *int64 + wantMin bool + wantMax bool + }{ + {name: "minimum only", min: awssdk.Int64(7), wantMin: true}, + {name: "maximum only", max: awssdk.Int64(365), wantMax: true}, + {name: "unbounded"}, + } { + t.Run(tc.name, func(t *testing.T) { + vault := mapBackupVault(backuptypes.BackupVaultListMember{ + MinRetentionDays: tc.min, + MaxRetentionDays: tc.max, + }, "ap-northeast-2") + if vault.MinRetentionKnown != tc.wantMin || vault.MaxRetentionKnown != tc.wantMax { + t.Fatalf("unexpected retention presence: %+v", vault) + } + }) + } + + unknown := mapBackupRecoveryPoint(backuptypes.RecoveryPointByBackupVault{}) + zero := mapBackupRecoveryPoint(backuptypes.RecoveryPointByBackupVault{BackupSizeInBytes: awssdk.Int64(0)}) + if unknown.SizeBytesKnown || !zero.SizeBytesKnown || zero.SizeBytes != 0 { + t.Fatalf("expected nil size to remain unknown and explicit zero to remain known: unknown=%+v zero=%+v", unknown, zero) + } +} + func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testing.T) { now := time.Date(2026, 8, 26, 2, 0, 0, 0, time.UTC) pointCalls := 0 From b760dc436299f9a22dd1f9e8ddb7c3741f9e5387 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 02:06:35 +0900 Subject: [PATCH 3/8] fix: address Backup review findings --- internal/app/screen_backup.go | 91 ++++++++++++++++++++++++--- internal/app/screen_backup_test.go | 50 +++++++++++++++ internal/app/screen_views.go | 6 +- internal/app/screen_views_test.go | 15 +++++ internal/services/aws/backup.go | 17 ++++- internal/services/aws/backup_model.go | 10 --- internal/services/aws/backup_test.go | 31 +++++++++ 7 files changed, 196 insertions(+), 24 deletions(-) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 22b9e5d..58b58fd 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -6,6 +6,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" awsservice "unic/internal/services/aws" ) @@ -214,7 +215,7 @@ func (bm *backupModel) updateList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) func (bm *backupModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { lines := bm.detailLines(*m) - visibleLines := max(m.height-8, 5) + visibleLines := bm.detailVisibleLines(*m) maxOffset := max(len(lines)-visibleLines, 0) switch msg.String() { case "q", "esc": @@ -291,17 +292,28 @@ func (bm backupModel) viewList(m Model) string { panel.WriteString(dimStyle.Render(message)) panel.WriteString("\n") } else { - panel.WriteString(dimStyle.Render(" VAULT STATE POINTS LOCK TYPE")) + widths := backupVaultColumnWidths(m) + panel.WriteString(dimStyle.Render(" " + renderBackupVaultColumns(widths, []string{"VAULT", "STATE", "POINTS", "LOCK", "TYPE"}))) panel.WriteString("\n") visibleLines := max(m.height-11-warningLines, 5) start := max(bm.idx-visibleLines+1, 0) - width := max(m.width-m.currentListPanelStyle().GetHorizontalFrameSize()-2, 1) for i := start; i < min(start+visibleLines, len(bm.filtered)); i++ { cursor, style := " ", normalStyle if i == bm.idx { cursor, style = "> ", selectedStyle } - row := truncateEC2DetailValue(escapeTerminalControls(bm.filtered[i].DisplayTitle()), width) + vault := bm.filtered[i] + lock := "-" + if vault.Locked { + lock = "locked" + } + row := renderBackupVaultColumns(widths, []string{ + vault.Name, + valueOrDashApp(vault.State), + fmt.Sprintf("%d", vault.RecoveryPointCount), + lock, + valueOrDashApp(vault.Type), + }) panel.WriteString(style.Render(cursor + m.renderHighlightedValue(filterBackupVaults, row))) panel.WriteString("\n") } @@ -324,15 +336,13 @@ func (bm backupModel) viewDetail(m Model) string { } b.WriteString(titleStyle.Render("AWS Backup Recovery — " + name)) b.WriteString("\n") - warningLines := 0 if len(bm.detailErrors) > 0 { b.WriteString(m.renderWarningSummary(len(bm.detailErrors), "detail lookup failures", bm.detailErrors[0].Error())) - warningLines = 2 } b.WriteString("\n") lines := bm.detailLines(m) - visibleLines := max(m.height-8-warningLines, 5) + visibleLines := bm.detailVisibleLines(m) start := min(bm.detailScroll, max(len(lines)-visibleLines, 0)) end := min(start+visibleLines, len(lines)) b.WriteString(m.renderListPanel(strings.Join(lines[start:end], "\n"))) @@ -341,6 +351,73 @@ func (bm backupModel) viewDetail(m Model) string { return b.String() } +func (bm backupModel) detailVisibleLines(m Model) int { + warningLines := 0 + if len(bm.detailErrors) > 0 { + warningLines = 2 + } + return max(m.height-8-warningLines, 5) +} + +func backupVaultColumnWidths(m Model) []int { + desired := []int{34, 14, 10, 10, 18} + if m.width <= 0 { + return desired + } + + widths := []int{10, 8, 8, 7, 6} + available := max(m.width-m.currentListPanelStyle().GetHorizontalFrameSize()-2, len(widths)) + total := 0 + for _, width := range widths { + total += width + } + for total > available { + for i := range widths { + if total <= available { + break + } + if widths[i] > 1 { + widths[i]-- + total-- + } + } + } + for total < available { + grew := false + for i := range widths { + if total >= available { + break + } + if widths[i] < desired[i] { + widths[i]++ + total++ + grew = true + } + } + if !grew { + break + } + } + return widths +} + +func renderBackupVaultColumns(widths []int, values []string) string { + var row strings.Builder + for i, width := range widths { + valueWidth := width + if i < len(widths)-1 { + valueWidth = max(width-2, 1) + } + value := truncateEC2DetailValue(escapeTerminalControls(values[i]), valueWidth) + column := lipgloss.NewStyle().Width(width).MaxWidth(width) + if i == 2 { + column = column.Align(lipgloss.Right) + } + row.WriteString(column.Render(value)) + } + return row.String() +} + func (bm backupModel) detailLines(m Model) []string { if bm.detail == nil { return []string{dimStyle.Render(" No backup vault detail loaded")} diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index 06c60e3..579fa6d 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -7,6 +7,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "unic/internal/config" awsservice "unic/internal/services/aws" @@ -58,7 +59,23 @@ func TestBackupVaultListRendersFiltersWarningsAndEscapesControls(t *testing.T) { t.Fatalf("expected terminal controls to be escaped, got %q", view) } + m.backup.filtered = []awsservice.BackupVault{{Name: "운영", State: "AVAILABLE", Type: "BACKUP_VAULT"}} + unicodeView := strings.Split(stripANSI(m.backup.viewList(m)), "\n") + headerColumn, rowColumn := -1, -1 + for _, line := range unicodeView { + if index := strings.Index(line, "STATE"); index >= 0 { + headerColumn = lipgloss.Width(line[:index]) + } + if index := strings.Index(line, "AVAILABLE"); index >= 0 { + rowColumn = lipgloss.Width(line[:index]) + } + } + if headerColumn < 0 || rowColumn != headerColumn { + t.Fatalf("expected Unicode vault row to align with header, header=%d row=%d", headerColumn, rowColumn) + } + m.storeFilterValue(filterBackupVaults, "failed") + m.backup.vaults = backupTestVaults() m.applyFilterTarget(filterBackupVaults) if len(m.backup.filtered) != 1 || !strings.Contains(m.backup.filtered[0].Name, "dev") { t.Fatalf("expected one failed vault match, got %+v", m.backup.filtered) @@ -113,6 +130,16 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { if m.backup.detailScroll == 0 || (!strings.Contains(scrolled, "Protected Resources") && !strings.Contains(scrolled, "Failed / Expired Jobs")) { t.Fatalf("expected page-down to reveal later recovery sections, scroll=%d view:\n%s", m.backup.detailScroll, scrolled) } + + m.height = 18 + m.backup.detailScroll = 0 + for range 100 { + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyDown}) + } + wantOffset := max(len(m.backup.detailLines(m))-m.backup.detailVisibleLines(m), 0) + if m.backup.detailScroll != wantOffset || !strings.Contains(stripANSI(m.backup.viewDetail(m)), "timeout") { + t.Fatalf("expected warning-adjusted final detail lines to be reachable, scroll=%d want=%d", m.backup.detailScroll, wantOffset) + } } func TestBackupOptionalNumericFieldsRenderWithoutFalseZeroes(t *testing.T) { @@ -153,6 +180,29 @@ func TestBackupIgnoresStaleDetailLoads(t *testing.T) { } } +func TestBackupDropsPriorContextCompletionForSameNamedVault(t *testing.T) { + m := New(testConfig(), "", "dev") + staleGeneration := m.commands.Renew() + m.commands.Renew() + + next := testConfig() + next.ContextName = "next" + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + m.screen = screenLoading + m.loadingReturnScreen = screenBackupVaultDetail + m.backup.selected = &awsservice.BackupVault{Name: "prod"} + + stale, _ := m.Update(genBoundMsg{gen: staleGeneration, msg: backupVaultDetailLoadedMsg{ + vaultName: "prod", + detail: &awsservice.BackupVaultDetail{Vault: awsservice.BackupVault{Name: "prod", Region: "old"}}, + }}) + model := stale.(Model) + if model.backup.detail != nil || model.screen != screenLoading { + t.Fatalf("expected prior-context Backup detail to be dropped, screen=%v detail=%+v", model.screen, model.backup.detail) + } +} + func TestBackupLoadCompletionStaysBehindGlobalOverlays(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/app/screen_views.go b/internal/app/screen_views.go index 687e357..44e3aa6 100644 --- a/internal/app/screen_views.go +++ b/internal/app/screen_views.go @@ -198,10 +198,8 @@ func (m Model) jumpToView(view config.ViewEntry) (tea.Model, tea.Cmd) { service: domain.AwsService(view.Service), feature: kind, }) - if view.Filter != "" { - if target, ok := featurePrimaryFilter[kind]; ok { - m.storeFilterValue(target, view.Filter) - } + if target, ok := featurePrimaryFilter[kind]; ok { + m.storeFilterValue(target, view.Filter) } return m.startFeature(kind) } diff --git a/internal/app/screen_views_test.go b/internal/app/screen_views_test.go index c4d3448..4df3fb9 100644 --- a/internal/app/screen_views_test.go +++ b/internal/app/screen_views_test.go @@ -113,6 +113,21 @@ func TestViewsCaptureAndApplyKMSFilter(t *testing.T) { } } +func TestViewsApplyUnfilteredBackupClearsExistingFilter(t *testing.T) { + m := viewsTestModel(t) + m.storeFilterValue(filterBackupVaults, "prod") + view := config.ViewEntry{ + Name: "all-backups", Context: "dev", + Service: string(domain.ServiceBackup), Feature: string(domain.FeatureBackupBrowser), + } + + next, cmd := m.applyView(view) + model := next.(Model) + if cmd == nil || model.filterValue(filterBackupVaults) != "" { + t.Fatalf("expected unfiltered Backup view to clear the prior filter, filter=%q", model.filterValue(filterBackupVaults)) + } +} + func TestViewsApplyAcrossContextsDefersJumpUntilSwitch(t *testing.T) { m := viewsTestModel(t) view := config.ViewEntry{ diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go index 6ee420e..09766a3 100644 --- a/internal/services/aws/backup.go +++ b/internal/services/aws/backup.go @@ -158,7 +158,11 @@ func sortBackupProtectedResources(items []BackupProtectedResource) { if !items[i].LastBackupAt.Equal(items[j].LastBackupAt) { return items[i].LastBackupAt.After(items[j].LastBackupAt) } - return normalizedSortKey(items[i].Name) < normalizedSortKey(items[j].Name) + left, right := normalizedSortKey(items[i].Name), normalizedSortKey(items[j].Name) + if left != right { + return left < right + } + return normalizedSortKey(items[i].ARN) < normalizedSortKey(items[j].ARN) }) } @@ -168,7 +172,10 @@ func sortBackupJobs(items []BackupJob) { if left != right { return left < right } - return items[i].CreatedAt.After(items[j].CreatedAt) + if !items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].CreatedAt.After(items[j].CreatedAt) + } + return normalizedSortKey(items[i].ID) < normalizedSortKey(items[j].ID) }) } @@ -276,7 +283,11 @@ func sortBackupRecoveryPoints(items []BackupRecoveryPoint) { if !items[i].CreatedAt.Equal(items[j].CreatedAt) { return items[i].CreatedAt.After(items[j].CreatedAt) } - return normalizedSortKey(items[i].ResourceName) < normalizedSortKey(items[j].ResourceName) + left, right := normalizedSortKey(items[i].ResourceName), normalizedSortKey(items[j].ResourceName) + if left != right { + return left < right + } + return normalizedSortKey(items[i].ARN) < normalizedSortKey(items[j].ARN) }) } diff --git a/internal/services/aws/backup_model.go b/internal/services/aws/backup_model.go index 2d81fde..6f7dd45 100644 --- a/internal/services/aws/backup_model.go +++ b/internal/services/aws/backup_model.go @@ -1,7 +1,6 @@ package aws import ( - "fmt" "strings" "time" ) @@ -30,15 +29,6 @@ func (v BackupVault) FilterText() string { return strings.ToLower(strings.Join([]string{v.ARN, v.Name, v.Region, v.State, v.Type, v.EncryptionKeyARN, v.EncryptionKeyType}, " ")) } -// DisplayTitle returns a column-aligned vault row. -func (v BackupVault) DisplayTitle() string { - lock := "-" - if v.Locked { - lock = "locked" - } - return fmt.Sprintf("%-32.32s %-12.12s %8d %-8s %s", v.Name, valueOrDash(v.State), v.RecoveryPointCount, lock, valueOrDash(v.Type)) -} - // BackupVaultDetail contains the recovery-readiness signals for one vault. type BackupVaultDetail struct { Vault BackupVault diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go index d8c5b3e..cd704e3 100644 --- a/internal/services/aws/backup_test.go +++ b/internal/services/aws/backup_test.go @@ -179,6 +179,37 @@ func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testi } } +func TestBackupDetailSortsUseUniqueTieBreakers(t *testing.T) { + now := time.Date(2026, 8, 26, 4, 0, 0, 0, time.UTC) + + resources := []BackupProtectedResource{ + {ARN: "arn:z", Name: "database", LastBackupAt: now}, + {ARN: "arn:a", Name: "database", LastBackupAt: now}, + } + sortBackupProtectedResources(resources) + if resources[0].ARN != "arn:a" { + t.Fatalf("expected protected resources to use ARN tie-breaker, got %+v", resources) + } + + jobs := []BackupJob{ + {ID: "job-z", State: "FAILED", CreatedAt: now}, + {ID: "job-a", State: "FAILED", CreatedAt: now}, + } + sortBackupJobs(jobs) + if jobs[0].ID != "job-a" { + t.Fatalf("expected jobs to use ID tie-breaker, got %+v", jobs) + } + + points := []BackupRecoveryPoint{ + {ARN: "arn:z", ResourceName: "database", Status: "COMPLETED", CreatedAt: now}, + {ARN: "arn:a", ResourceName: "database", Status: "COMPLETED", CreatedAt: now}, + } + sortBackupRecoveryPoints(points) + if points[0].ARN != "arn:a" { + t.Fatalf("expected recovery points to use ARN tie-breaker, got %+v", points) + } +} + func TestGetBackupVaultDetailReturnsWarningsWhenAllSectionsAreDenied(t *testing.T) { client := &mockBackupClient{ listPoints: func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { From cfc31b710ec1d222e7fd65cd39682574b8094900 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 02:26:14 +0900 Subject: [PATCH 4/8] fix: refresh Backup vault metadata - reload selected vault metadata before refreshing recovery detail - cover changed recovery-point counts and Vault Lock posture --- internal/app/screen_backup.go | 43 +++++++++++++++++++-- internal/app/screen_backup_test.go | 62 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 58b58fd..1b27734 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -101,6 +101,10 @@ func (bm *backupModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bm.finishError(m, msg.err) return *m, nil, true } + if msg.detail != nil { + selected := msg.detail.Vault + bm.selected = &selected + } bm.detail = msg.detail bm.detailErrors = msg.warnings bm.detailScroll = 0 @@ -207,7 +211,7 @@ func (bm *backupModel) updateList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) bm.detail = nil bm.detailErrors = nil bm.errorActive = false - return m.startLoadingFor(screenBackupVaultDetail, "Loading backup recovery posture...", []string{selected.Name}, bm.loadDetail(*m, selected)) + return m.startLoadingFor(screenBackupVaultDetail, "Loading backup recovery posture...", []string{selected.Name}, bm.loadDetail(*m, selected, false)) } } return *m, nil @@ -232,7 +236,7 @@ func (bm *backupModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cm case "r": if bm.selected != nil { bm.errorActive = false - return m.startLoadingFor(screenBackupVaultDetail, "Refreshing backup recovery posture...", []string{bm.selected.Name}, bm.loadDetail(*m, *bm.selected)) + return m.startLoadingFor(screenBackupVaultDetail, "Refreshing backup recovery posture...", []string{bm.selected.Name}, bm.loadDetail(*m, *bm.selected, true)) } } return *m, nil @@ -254,7 +258,7 @@ func (bm backupModel) loadVaults(m Model) tea.Cmd { } } -func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault) tea.Cmd { +func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault, refreshVault bool) tea.Cmd { return func() tea.Msg { ctx := m.commandContext() repo := m.awsRepo @@ -265,11 +269,42 @@ func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault) tea.Cmd return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: err} } } - detail, warnings, err := repo.GetBackupVaultDetail(ctx, vault) + var warnings []error + if refreshVault { + vaults, listWarnings, err := repo.ListBackupVaults(ctx) + if err != nil { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: err} + } + warnings = append(warnings, listWarnings...) + refreshed, ok := findBackupVault(vaults, vault) + if !ok { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: fmt.Errorf("backup vault %s is no longer available", vault.Name)} + } + vault = refreshed + } + + detail, detailWarnings, err := repo.GetBackupVaultDetail(ctx, vault) + warnings = append(warnings, detailWarnings...) return backupVaultDetailLoadedMsg{vaultName: vault.Name, detail: detail, warnings: warnings, err: err} } } +func findBackupVault(vaults []awsservice.BackupVault, selected awsservice.BackupVault) (awsservice.BackupVault, bool) { + if selected.ARN != "" { + for _, vault := range vaults { + if vault.ARN == selected.ARN { + return vault, true + } + } + } + for _, vault := range vaults { + if vault.Name == selected.Name { + return vault, true + } + } + return awsservice.BackupVault{}, false +} + func (bm backupModel) viewList(m Model) string { var b, panel strings.Builder b.WriteString(m.renderStatusBar()) diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index 579fa6d..b935074 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -1,11 +1,15 @@ package app import ( + "context" "errors" "strings" "testing" "time" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -13,6 +17,26 @@ import ( awsservice "unic/internal/services/aws" ) +type appBackupClient struct { + listVaults func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) +} + +func (c *appBackupClient) ListBackupVaults(ctx context.Context, input *backup.ListBackupVaultsInput, opts ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return c.listVaults(ctx, input, opts...) +} + +func (*appBackupClient) ListRecoveryPointsByBackupVault(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return &backup.ListRecoveryPointsByBackupVaultOutput{}, nil +} + +func (*appBackupClient) ListProtectedResourcesByBackupVault(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return &backup.ListProtectedResourcesByBackupVaultOutput{}, nil +} + +func (*appBackupClient) ListBackupJobs(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return &backup.ListBackupJobsOutput{}, nil +} + func backupTestVaults() []awsservice.BackupVault { return []awsservice.BackupVault{ {Name: "prod", ARN: "arn:vault:prod", Region: "ap-northeast-2", State: "AVAILABLE", Type: "BACKUP_VAULT", RecoveryPointCount: 3, Locked: true, EncryptionKeyARN: "arn:kms:prod"}, @@ -142,6 +166,44 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { } } +func TestBackupDetailRefreshReloadsVaultMetadata(t *testing.T) { + m := New(testConfig(), "", "dev") + stale := awsservice.BackupVault{ + Name: "prod", ARN: "arn:vault:prod", RecoveryPointCount: 1, + } + m.screen = screenBackupVaultDetail + m.backup.selected = &stale + m.backup.detail = &awsservice.BackupVaultDetail{Vault: stale} + m.awsRepo = &awsservice.AwsRepository{Region: "ap-northeast-2", BackupClient: &appBackupClient{ + listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + if input.NextToken != nil { + t.Fatalf("expected a single vault-list page, got token %q", awssdk.ToString(input.NextToken)) + } + return &backup.ListBackupVaultsOutput{BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("prod"), BackupVaultArn: awssdk.String("arn:vault:prod"), + NumberOfRecoveryPoints: 9, Locked: awssdk.Bool(true), MinRetentionDays: awssdk.Int64(30), + }}}, nil + }, + }} + + updated, cmd := m.backup.updateDetail(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + m = updated.(Model) + result := runBatchedUserCmd(t, cmd) + next, _ := m.Update(result) + m = next.(Model) + + if m.backup.detail == nil || m.backup.detail.Vault.RecoveryPointCount != 9 || !m.backup.detail.Vault.Locked || !m.backup.detail.Vault.MinRetentionKnown { + t.Fatalf("expected refreshed vault metadata in detail, got %+v", m.backup.detail) + } + if m.backup.selected == nil || m.backup.selected.RecoveryPointCount != 9 || !m.backup.selected.Locked { + t.Fatalf("expected refreshed selected vault, got %+v", m.backup.selected) + } + lines := stripANSI(strings.Join(m.backup.detailLines(m), "\n")) + if !strings.Contains(lines, "9") || !strings.Contains(lines, "locked (minimum 30 days)") { + t.Fatalf("expected refreshed recovery count and Vault Lock posture, got:\n%s", lines) + } +} + func TestBackupOptionalNumericFieldsRenderWithoutFalseZeroes(t *testing.T) { for _, tc := range []struct { name string From b05acf497fb4aed561375ba967d1c503fc6157ae Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 02:48:49 +0900 Subject: [PATCH 5/8] fix: preserve Backup refresh results - reconcile refreshed vault metadata into the list state\n- keep prior vault details when a paginated refresh is incomplete --- internal/app/screen_backup.go | 35 ++++++++++++++++-- internal/app/screen_backup_test.go | 59 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 1b27734..4ae82ab 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -103,6 +103,7 @@ func (bm *backupModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, } if msg.detail != nil { selected := msg.detail.Vault + bm.reconcileVault(selected, m.filterValue(filterBackupVaults)) bm.selected = &selected } bm.detail = msg.detail @@ -188,6 +189,24 @@ func (bm *backupModel) ApplyFilter(m *Model, target filterTarget) bool { return true } +func (bm *backupModel) reconcileVault(vault awsservice.BackupVault, query string) { + for i := range bm.vaults { + if !sameBackupVault(bm.vaults[i], vault) { + continue + } + bm.vaults[i] = vault + bm.filtered = applyFilter(bm.vaults, query) + bm.idx = clampListIndex(bm.idx, len(bm.filtered)) + for i := range bm.filtered { + if sameBackupVault(bm.filtered[i], vault) { + bm.idx = i + break + } + } + return + } +} + func (bm *backupModel) updateList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { if cmd, handled := m.updateSharedFilter(msg, filterBackupVaults); handled { return *m, cmd @@ -278,9 +297,12 @@ func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault, refreshV warnings = append(warnings, listWarnings...) refreshed, ok := findBackupVault(vaults, vault) if !ok { - return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: fmt.Errorf("backup vault %s is no longer available", vault.Name)} + if len(listWarnings) == 0 { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: fmt.Errorf("backup vault %s is no longer available", vault.Name)} + } + } else { + vault = refreshed } - vault = refreshed } detail, detailWarnings, err := repo.GetBackupVaultDetail(ctx, vault) @@ -292,7 +314,7 @@ func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault, refreshV func findBackupVault(vaults []awsservice.BackupVault, selected awsservice.BackupVault) (awsservice.BackupVault, bool) { if selected.ARN != "" { for _, vault := range vaults { - if vault.ARN == selected.ARN { + if sameBackupVault(vault, selected) { return vault, true } } @@ -305,6 +327,13 @@ func findBackupVault(vaults []awsservice.BackupVault, selected awsservice.Backup return awsservice.BackupVault{}, false } +func sameBackupVault(left, right awsservice.BackupVault) bool { + if left.ARN != "" && right.ARN != "" { + return left.ARN == right.ARN + } + return left.Name == right.Name +} + func (bm backupModel) viewList(m Model) string { var b, panel strings.Builder b.WriteString(m.renderStatusBar()) diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index b935074..a8e717f 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -174,6 +174,8 @@ func TestBackupDetailRefreshReloadsVaultMetadata(t *testing.T) { m.screen = screenBackupVaultDetail m.backup.selected = &stale m.backup.detail = &awsservice.BackupVaultDetail{Vault: stale} + m.backup.vaults = []awsservice.BackupVault{stale} + m.backup.filtered = []awsservice.BackupVault{stale} m.awsRepo = &awsservice.AwsRepository{Region: "ap-northeast-2", BackupClient: &appBackupClient{ listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { if input.NextToken != nil { @@ -202,6 +204,63 @@ func TestBackupDetailRefreshReloadsVaultMetadata(t *testing.T) { if !strings.Contains(lines, "9") || !strings.Contains(lines, "locked (minimum 30 days)") { t.Fatalf("expected refreshed recovery count and Vault Lock posture, got:\n%s", lines) } + + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyEsc}) + if len(m.backup.vaults) != 1 || m.backup.vaults[0].RecoveryPointCount != 9 || len(m.backup.filtered) != 1 || !m.backup.filtered[0].Locked { + t.Fatalf("expected refreshed vault metadata in the canonical list, vaults=%+v filtered=%+v", m.backup.vaults, m.backup.filtered) + } + updated, cmd = m.backup.updateList(&m, tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + result = runBatchedUserCmd(t, cmd) + next, _ = m.Update(result) + m = next.(Model) + if m.backup.detail == nil || m.backup.detail.Vault.RecoveryPointCount != 9 || !m.backup.detail.Vault.Locked { + t.Fatalf("expected refreshed metadata after returning to the detail, got %+v", m.backup.detail) + } +} + +func TestBackupDetailRefreshKeepsPriorVaultWhenListingIsPartial(t *testing.T) { + m := New(testConfig(), "", "dev") + selected := awsservice.BackupVault{Name: "prod", ARN: "arn:vault:prod", RecoveryPointCount: 1} + m.screen = screenBackupVaultDetail + m.backup.selected = &selected + m.backup.detail = &awsservice.BackupVaultDetail{Vault: selected} + m.backup.vaults = []awsservice.BackupVault{selected} + m.backup.filtered = []awsservice.BackupVault{selected} + calls := 0 + m.awsRepo = &awsservice.AwsRepository{BackupClient: &appBackupClient{ + listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + calls++ + if calls == 1 { + return &backup.ListBackupVaultsOutput{ + NextToken: awssdk.String("page-2"), + BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("dev"), BackupVaultArn: awssdk.String("arn:vault:dev"), + }}, + }, nil + } + if awssdk.ToString(input.NextToken) != "page-2" { + t.Fatalf("expected second-page token, got %q", awssdk.ToString(input.NextToken)) + } + return nil, errors.New("page denied") + }, + }} + + updated, cmd := m.backup.updateDetail(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + m = updated.(Model) + result := runBatchedUserCmd(t, cmd) + next, _ := m.Update(result) + m = next.(Model) + + if calls != 2 || m.screen != screenBackupVaultDetail || m.backup.errorActive { + t.Fatalf("expected usable detail after partial listing, calls=%d screen=%v state=%+v", calls, m.screen, m.backup) + } + if m.backup.detail == nil || m.backup.detail.Vault.ARN != selected.ARN || m.backup.detail.Vault.RecoveryPointCount != 1 { + t.Fatalf("expected prior selected vault metadata to remain available, got %+v", m.backup.detail) + } + if len(m.backup.detailErrors) != 1 || !strings.Contains(m.backup.detailErrors[0].Error(), "page denied") { + t.Fatalf("expected partial-list warning, got %v", m.backup.detailErrors) + } } func TestBackupOptionalNumericFieldsRenderWithoutFalseZeroes(t *testing.T) { From b9d6c63e4c27eaf26a991f1bbb941f1d47d019fe Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 03:08:19 +0900 Subject: [PATCH 6/8] fix: surface Backup recovery warnings - render every partial detail failure without hiding scrollable content - preserve completed-job message categories in recovery posture --- internal/app/screen_backup.go | 8 +++++++- internal/app/screen_backup_test.go | 8 ++++---- internal/app/styles.go | 10 +++++++--- internal/services/aws/backup.go | 15 ++++++++------- internal/services/aws/backup_model.go | 19 ++++++++++--------- internal/services/aws/backup_test.go | 3 ++- 6 files changed, 38 insertions(+), 25 deletions(-) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 4ae82ab..79b3491 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -402,6 +402,9 @@ func (bm backupModel) viewDetail(m Model) string { b.WriteString("\n") if len(bm.detailErrors) > 0 { b.WriteString(m.renderWarningSummary(len(bm.detailErrors), "detail lookup failures", bm.detailErrors[0].Error())) + for _, detailError := range bm.detailErrors[1:] { + b.WriteString(m.renderWarningDetail(detailError.Error())) + } } b.WriteString("\n") @@ -418,7 +421,7 @@ func (bm backupModel) viewDetail(m Model) string { func (bm backupModel) detailVisibleLines(m Model) int { warningLines := 0 if len(bm.detailErrors) > 0 { - warningLines = 2 + warningLines = len(bm.detailErrors) + 1 } return max(m.height-8-warningLines, 5) } @@ -556,6 +559,9 @@ func (bm backupModel) detailLines(m Model) []string { if job.StatusMessage != "" { lines = append(lines, backupDetailLine(m, "Reason", job.StatusMessage)) } + if job.MessageCategory != "" { + lines = append(lines, backupDetailLine(m, "Message Category", job.MessageCategory)) + } } return lines } diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index a8e717f..67174db 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -126,15 +126,15 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { ARN: "arn:point", ResourceName: "database\x1b[31m", ResourceType: "RDS", Status: "PARTIAL", StatusMessage: "access denied", CreatedAt: now, DeleteAt: now.Add(24 * time.Hour), SizeBytes: 2048, SizeBytesKnown: true, }}, ProtectedResources: []awsservice.BackupProtectedResource{{Name: "database", Type: "RDS", ARN: "arn:db", LastBackupAt: now, LastRecoveryPointARN: "arn:point"}}, - FailedJobs: []awsservice.BackupJob{{ID: "job-1", ResourceName: "database", ResourceType: "RDS", State: "FAILED", StatusMessage: "timeout", CreatedAt: now}}, + FailedJobs: []awsservice.BackupJob{{ID: "job-1", ResourceName: "database", ResourceType: "RDS", State: "COMPLETED", MessageCategory: "PERMISSIONS", CreatedAt: now}}, } - m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{vaultName: "prod", detail: detail, warnings: []error{errors.New("protected resources denied")}}) + m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{vaultName: "prod", detail: detail, warnings: []error{errors.New("protected resources denied"), errors.New("jobs denied")}}) if m.screen != screenBackupVaultDetail { t.Fatalf("expected backup detail, got %v", m.screen) } initial := m.backup.viewDetail(m) plain := stripANSI(initial) - for _, want := range []string{"AWS Backup Recovery", "detail lookup failures", "Recovery Points"} { + for _, want := range []string{"AWS Backup Recovery", "detail lookup failures", "protected resources denied", "jobs denied", "Recovery Points"} { if !strings.Contains(plain, want) { t.Fatalf("expected %q in initial detail, got:\n%s", want, plain) } @@ -161,7 +161,7 @@ func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyDown}) } wantOffset := max(len(m.backup.detailLines(m))-m.backup.detailVisibleLines(m), 0) - if m.backup.detailScroll != wantOffset || !strings.Contains(stripANSI(m.backup.viewDetail(m)), "timeout") { + if m.backup.detailScroll != wantOffset || !strings.Contains(stripANSI(m.backup.viewDetail(m)), "PERMISSIONS") { t.Fatalf("expected warning-adjusted final detail lines to be reachable, scroll=%d want=%d", m.backup.detailScroll, wantOffset) } } diff --git a/internal/app/styles.go b/internal/app/styles.go index 65a4f8d..e7880d0 100644 --- a/internal/app/styles.go +++ b/internal/app/styles.go @@ -172,13 +172,17 @@ func (m Model) renderListPanel(content string) string { } func (m Model) renderWarningSummary(count int, label, first string) string { - first, _, _ = strings.Cut(first, "\n") + return errorStyle.Render(fmt.Sprintf(" Warnings: %d %s", count, label)) + "\n" + + m.renderWarningDetail(first) +} + +func (m Model) renderWarningDetail(detail string) string { + detail, _, _ = strings.Cut(detail, "\n") width := m.width - 4 if width <= 0 { width = 88 } - return errorStyle.Render(fmt.Sprintf(" Warnings: %d %s", count, label)) + "\n" + - dimStyle.Render(" "+truncateEC2DetailValue(escapeTerminalControls(first), width)) + "\n" + return dimStyle.Render(" "+truncateEC2DetailValue(escapeTerminalControls(detail), width)) + "\n" } func (m Model) renderHelpBar(content string) string { diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go index 09766a3..71919b9 100644 --- a/internal/services/aws/backup.go +++ b/internal/services/aws/backup.go @@ -249,13 +249,14 @@ func mapBackupProtectedResource(item backuptypes.ProtectedResource) BackupProtec func mapBackupJob(item backuptypes.BackupJob) BackupJob { mapped := BackupJob{ - ID: awssdk.ToString(item.BackupJobId), - ResourceARN: awssdk.ToString(item.ResourceArn), - ResourceName: awssdk.ToString(item.ResourceName), - ResourceType: awssdk.ToString(item.ResourceType), - State: string(item.State), - StatusMessage: awssdk.ToString(item.StatusMessage), - SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + ID: awssdk.ToString(item.BackupJobId), + ResourceARN: awssdk.ToString(item.ResourceArn), + ResourceName: awssdk.ToString(item.ResourceName), + ResourceType: awssdk.ToString(item.ResourceType), + State: string(item.State), + StatusMessage: awssdk.ToString(item.StatusMessage), + MessageCategory: awssdk.ToString(item.MessageCategory), + SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), } if item.CreationDate != nil { mapped.CreatedAt = *item.CreationDate diff --git a/internal/services/aws/backup_model.go b/internal/services/aws/backup_model.go index 6f7dd45..9478076 100644 --- a/internal/services/aws/backup_model.go +++ b/internal/services/aws/backup_model.go @@ -76,13 +76,14 @@ type BackupProtectedResource struct { // BackupJob contains a recent failed or expired backup job. type BackupJob struct { - ID string - ResourceARN string - ResourceName string - ResourceType string - State string - StatusMessage string - SizeBytes int64 - CreatedAt time.Time - CompletedAt time.Time + ID string + ResourceARN string + ResourceName string + ResourceType string + State string + StatusMessage string + MessageCategory string + SizeBytes int64 + CreatedAt time.Time + CompletedAt time.Time } diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go index cd704e3..047be53 100644 --- a/internal/services/aws/backup_test.go +++ b/internal/services/aws/backup_test.go @@ -154,6 +154,7 @@ func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testi } return &backup.ListBackupJobsOutput{BackupJobs: []backuptypes.BackupJob{ {BackupJobId: awssdk.String("ok"), State: backuptypes.BackupJobStateCompleted, MessageCategory: awssdk.String("SUCCESS")}, + {BackupJobId: awssdk.String("completed-with-issues"), State: backuptypes.BackupJobStateCompleted, MessageCategory: awssdk.String("PERMISSIONS")}, {BackupJobId: awssdk.String("expired"), ResourceName: awssdk.String("old"), State: backuptypes.BackupJobStateExpired, CreationDate: awssdk.Time(now.Add(-time.Hour))}, {BackupJobId: awssdk.String("failed"), ResourceName: awssdk.String("db"), State: backuptypes.BackupJobStateFailed, StatusMessage: awssdk.String("access denied"), CreationDate: awssdk.Time(now)}, }}, nil @@ -174,7 +175,7 @@ func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testi if len(detail.ProtectedResources) != 1 || detail.ProtectedResources[0].LastRecoveryPointARN != "arn:healthy" { t.Fatalf("unexpected protected resources: %+v", detail.ProtectedResources) } - if len(detail.FailedJobs) != 2 || detail.FailedJobs[0].ID != "failed" || detail.FailedJobs[1].ID != "expired" { + if len(detail.FailedJobs) != 3 || detail.FailedJobs[0].ID != "failed" || detail.FailedJobs[1].ID != "expired" || detail.FailedJobs[2].MessageCategory != "PERMISSIONS" { t.Fatalf("expected only failure jobs in priority order, got %+v", detail.FailedJobs) } } From 575f15acf7a57286bcc9cac856e1400458a4b332 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 03:11:18 +0900 Subject: [PATCH 7/8] fix: stop Backup detail loads on cancellation - return before starting later independent section loads - cover cancellation between recovery-detail sections --- internal/services/aws/backup.go | 6 ++++++ internal/services/aws/backup_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go index 71919b9..8856914 100644 --- a/internal/services/aws/backup.go +++ b/internal/services/aws/backup.go @@ -62,12 +62,18 @@ func (r *AwsRepository) GetBackupVaultDetail(ctx context.Context, vault BackupVa warnings = append(warnings, err) } detail.RecoveryPoints = recoveryPoints + if err := ctx.Err(); err != nil { + return nil, nil, err + } resources, err := r.listBackupProtectedResources(ctx, vault.Name) if err != nil { warnings = append(warnings, err) } detail.ProtectedResources = resources + if err := ctx.Err(); err != nil { + return nil, nil, err + } jobs, err := r.listFailedBackupJobs(ctx, vault.Name) if err != nil { diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go index 047be53..fc5ee7c 100644 --- a/internal/services/aws/backup_test.go +++ b/internal/services/aws/backup_test.go @@ -180,6 +180,30 @@ func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testi } } +func TestGetBackupVaultDetailStopsBetweenSectionsWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + resourcesCalled, jobsCalled := false, false + client := &mockBackupClient{ + listPoints: func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + cancel() + return &backup.ListRecoveryPointsByBackupVaultOutput{}, nil + }, + listResources: func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + resourcesCalled = true + return &backup.ListProtectedResourcesByBackupVaultOutput{}, nil + }, + listJobs: func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + jobsCalled = true + return &backup.ListBackupJobsOutput{}, nil + }, + } + + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(ctx, BackupVault{Name: "prod"}) + if !errors.Is(err, context.Canceled) || detail != nil || warnings != nil || resourcesCalled || jobsCalled { + t.Fatalf("expected cancellation before later sections, detail=%+v warnings=%v err=%v resources=%t jobs=%t", detail, warnings, err, resourcesCalled, jobsCalled) + } +} + func TestBackupDetailSortsUseUniqueTieBreakers(t *testing.T) { now := time.Date(2026, 8, 26, 4, 0, 0, 0, time.UTC) From 7c27e38e484e44f0c86a9906870085989816c7ce Mon Sep 17 00:00:00 2001 From: YoungJin Date: Wed, 26 Aug 2026 03:27:14 +0900 Subject: [PATCH 8/8] fix: preserve Backup loads through context add - route Add Context through the existing Backup overlay chain - cover returning from context creation to a completed vault load --- internal/app/screen_backup.go | 2 ++ internal/app/screen_backup_test.go | 33 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go index 79b3491..fe564e2 100644 --- a/internal/app/screen_backup.go +++ b/internal/app/screen_backup.go @@ -64,6 +64,8 @@ func backupOverlayPrevious(m *Model, current screen) *screen { return &m.views.prevScreen case screenContextPicker: return &m.ctxPrevScreen + case screenContextAdd: + return &m.ctxPrevScreen case screenRegionPicker: return &m.regionPrevScreen default: diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go index 67174db..e1f93fa 100644 --- a/internal/app/screen_backup_test.go +++ b/internal/app/screen_backup_test.go @@ -349,6 +349,39 @@ func TestBackupLoadCompletionStaysBehindGlobalOverlays(t *testing.T) { } } +func TestBackupLoadCompletionBehindContextAddReturnsToVaultList(t *testing.T) { + m := New(testConfig(), "", "dev") + m.cfg.ContextName = "dev" + started, _ := m.backup.Start(&m) + m = started.(Model) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'C'}}) + m = updated.(Model) + updated, _ = m.Update(contextsLoadedMsg{contexts: testContexts()}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + m = updated.(Model) + + updated, _ = m.Update(backupVaultsLoadedMsg{vaults: backupTestVaults()}) + m = updated.(Model) + if m.screen != screenContextAdd || m.ctxPrevScreen != screenBackupVaultList { + t.Fatalf("expected context add to stay open over the loaded vault list, screen=%v previous=%v", m.screen, m.ctxPrevScreen) + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if cmd == nil { + t.Fatal("expected context add cancel to reload the context picker") + } + updated, _ = m.Update(cmd()) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.screen != screenBackupVaultList || len(m.backup.vaults) != len(backupTestVaults()) { + t.Fatalf("expected context flow to return to the loaded vault list, screen=%v vaults=%d", m.screen, len(m.backup.vaults)) + } +} + func TestBackupErrorBehindContextPickerIsClearedByContextSwitch(t *testing.T) { m := New(testConfig(), "", "dev") m.screen = screenContextPicker