Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ Context ordering:
| VPC | VPC Browser |
| VPC | Reachability Analyzer |
| RDS | RDS Browser |
| RDS | Instance Class Modification |
| Route53 | Route53 Browser |
| Secrets Manager | Secrets Browser |
| CloudWatch | Metrics Viewer |
Expand Down Expand Up @@ -366,7 +367,7 @@ checks:
| EC2 Instance Browser | `r` refresh, `/` filter, `Enter` detail, detail `g/a/t/b/n` opens related security groups/ASG/target groups/load balancers/listeners |
| Security Groups | `a` add rule, `d` delete rule, `Tab` switch ingress/egress |
| Reachability Analyzer | Region select first, `←`/`→` or `Tab` change type, `/` filter, `Enter` advance, `Tab`/`↑`/`↓` move config fields, `←`/`→` protocol, `r` rerun |
| RDS | `s` start, `x` stop, `f` failover, `r` refresh |
| RDS | `s` start, `x` stop, `f` failover, `m` modify instance class (filterable class picker, `Tab` apply-immediately toggle, type-to-confirm), `r` refresh |
| Route53 | `c` create, `e` edit, `d` delete |
| IAM Key Rotation | `r` rotate, `c` copy exports, `a` apply and verify, `d` deactivate old key, `x` delete old key |
| Bedrock API Keys | `c` create, choose current IAM user or another user, `r` rotate secret, `d` delete, type the IAM user/key ID to confirm, `c` copy one-time key without printing it, `e` copy `AWS_BEARER_TOKEN_BEDROCK` export |
Expand All @@ -391,6 +392,8 @@ The EKS Browser includes a managed add-on status view for each cluster. Add-on r

The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy.

The RDS detail screen can resize an instance with `m`: unic loads the orderable DB instance classes for the instance's engine/version in the active region into a filterable picker (current class marked), then a confirmation screen shows the current and new class with a `Tab`-toggleable apply-immediately choice (default: next maintenance window) and requires typing the instance identifier before calling ModifyDBInstance. After submitting, the detail screen polls the instance status until the change settles.

The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Template detail includes a Safe Run Preview that summarizes blast radius, target selection modes, action count, active stop conditions, IAM role, and warnings for missing stop conditions, missing role ARN, broad selection, or unbounded selectors. The preview also states the template ID that any future execution path must type to confirm before a run can start. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata.

The EKS Browser includes a current-version upgrade readiness view for each selected cluster. It compares the control plane version with managed node group versions, checks installed managed add-on versions against EKS compatibility metadata for the current cluster version, includes EKS `UPGRADE_READINESS` insights, and highlights blockers or warnings before planning a target-version upgrade.
Expand Down
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const (
screenReachabilityResult
screenRDSList
screenRDSDetail
screenRDSClassPicker
screenRDSConfirm
screenRoute53ZoneList
screenRoute53RecordList
Expand Down
13 changes: 13 additions & 0 deletions internal/app/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func (m Model) currentScreenShortcuts() []helpShortcut {
return listScreenShortcuts("open the selected instance", "go back to the feature list", true, false)
case screenRDSDetail:
shortcuts := []helpShortcut{
{"m", "Modify the instance class"},
{"r", "Refresh the selected instance status"},
{"q / esc", "Go back to the instance list"},
}
Expand All @@ -238,13 +239,23 @@ func (m Model) currentScreenShortcuts() []helpShortcut {
shortcuts = append([]helpShortcut{{"f", "Trigger failover for the selected instance or cluster"}}, shortcuts...)
}
return shortcuts
case screenRDSClassPicker:
return listScreenShortcuts("choose the class and continue to confirmation", "go back to the instance detail", true, false)
case screenRDSConfirm:
if m.rds.action == "start" {
return []helpShortcut{
{"y / enter", "Confirm the start action"},
{"n / esc", "Cancel and return to the detail screen"},
}
}
if m.rds.action == "modify" {
return []helpShortcut{
{"type", "Enter the instance identifier to confirm"},
{"tab", "Toggle apply-immediately"},
{"enter", "Confirm the class modification"},
{"esc", "Go back to the class picker"},
}
}
target := "instance identifier"
if m.rds.selected != nil && m.rds.selected.IsClusterMember() {
target = "cluster identifier"
Expand Down Expand Up @@ -834,6 +845,8 @@ func (m Model) helpScreenTitle() string {
return "RDS Instances"
case screenRDSDetail:
return "RDS Detail"
case screenRDSClassPicker:
return "RDS Instance Class Picker"
case screenRDSConfirm:
return "RDS Confirmation"
case screenRoute53ZoneList:
Expand Down
5 changes: 5 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ type rdsInstancesLoadedMsg struct {
instances []awsservice.RDSInstance
}

type rdsClassesLoadedMsg struct {
instanceID string
classes []string
}

type rdsActionDoneMsg struct {
action string
instanceID string
Expand Down
194 changes: 192 additions & 2 deletions internal/app/screen_rds.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@ type rdsModel struct {
filtered []awsservice.RDSInstance
idx int
selected *awsservice.RDSInstance
action string // "start", "stop", "failover"
action string // "start", "stop", "failover", "modify"
confirmInput string // typed input for destructive action confirmation
polling bool

// Instance class modification
classes []string
filteredClasses []string
classIdx int
classFilter string
classFiltering bool
pendingClass string
applyImmediately bool
}

func newRDSModel() rdsModel {
Expand All @@ -38,6 +47,18 @@ func (rm *rdsModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bo
m.screen = screenRDSList
return *m, nil, true

case rdsClassesLoadedMsg:
if rm.selected == nil || rm.selected.DBInstanceID != msg.instanceID {
return *m, nil, true
}
rm.classes = msg.classes
rm.filteredClasses = msg.classes
rm.classIdx = 0
rm.classFilter = ""
rm.classFiltering = false
m.screen = screenRDSClassPicker
return *m, nil, true

case rdsActionDoneMsg:
if msg.err != nil {
m.errMsg = msg.err.Error()
Expand Down Expand Up @@ -85,6 +106,9 @@ func (rm *rdsModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, boo
case screenRDSDetail:
newM, cmd := rm.updateDetail(m, msg)
return newM, cmd, true
case screenRDSClassPicker:
newM, cmd := rm.updateClassPicker(m, msg)
return newM, cmd, true
case screenRDSConfirm:
newM, cmd := rm.updateConfirm(m, msg)
return newM, cmd, true
Expand All @@ -99,6 +123,8 @@ func (rm rdsModel) View(m Model) (string, bool) {
return rm.viewList(m), true
case screenRDSDetail:
return rm.viewDetail(m), true
case screenRDSClassPicker:
return rm.viewClassPicker(m), true
case screenRDSConfirm:
return rm.viewConfirm(m), true
default:
Expand Down Expand Up @@ -165,6 +191,14 @@ func (rm *rdsModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd)
rm.confirmInput = ""
m.screen = screenRDSConfirm
}
case "m":
if rm.selected != nil {
return m.startLoadingWithMessage(
"Loading instance classes...",
[]string{rm.selected.DBInstanceID, fmt.Sprintf("engine=%s %s", rm.selected.Engine, rm.selected.EngineVersion)},
rm.loadClasses(*m),
)
}
case "r":
if rm.selected != nil {
return *m, rm.pollStatus(*m, rm.selected.DBInstanceID)
Expand All @@ -173,6 +207,45 @@ func (rm *rdsModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd)
return *m, nil
}

func (rm *rdsModel) updateClassPicker(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if rm.classFiltering {
newFilter, deactivate, changed := handleFilterKey(key, rm.classFilter)
rm.classFilter = newFilter
if deactivate {
rm.classFiltering = false
}
if changed {
rm.filteredClasses = applyStringFilter(rm.classes, rm.classFilter)
rm.classIdx = 0
}
if !isFilterNavigationKey(key) {
return *m, nil
}
}

switch key {
case "q", "esc":
m.screen = screenRDSDetail
case "up", "k":
rm.classIdx = previousListIndex(rm.classIdx, len(rm.filteredClasses))
case "down", "j":
rm.classIdx = nextListIndex(rm.classIdx, len(rm.filteredClasses))
case "/":
rm.classFiltering = true
case "enter":
if len(rm.filteredClasses) == 0 || rm.classIdx >= len(rm.filteredClasses) {
return *m, nil
}
rm.pendingClass = rm.filteredClasses[rm.classIdx]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] picker가 (current) 클래스를 그대로 선택/확인할 수 있게 두어서 사용자가 동일한 class로 ModifyDBInstance를 호출하는 무의미한 경로가 열려 있습니다. 현재 class 선택 시 여기서 바로 막거나 목록에서 제외해 주세요. 지금 테스트도 current marker 렌더링만 확인하고 이 경로는 놓칩니다.

rm.applyImmediately = false
rm.action = "modify"
rm.confirmInput = ""
m.screen = screenRDSConfirm
}
return *m, nil
}

func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Start action uses simple y/n confirmation
if rm.action == "start" {
Expand All @@ -190,17 +263,27 @@ func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd)

// Stop/failover require typing the identifier to confirm
// For Aurora cluster members, confirm with cluster ID; for standalone, instance ID
// Class modification is instance-level, so it always confirms with the instance ID
confirmTarget := ""
if rm.selected != nil {
if rm.selected.IsClusterMember() {
if rm.selected.IsClusterMember() && rm.action != "modify" {
confirmTarget = rm.selected.ClusterID
} else {
confirmTarget = rm.selected.DBInstanceID
}
}
switch msg.String() {
case "esc":
if rm.action == "modify" {
m.screen = screenRDSClassPicker
return *m, nil
}
m.screen = screenRDSDetail
case "tab":
if rm.action == "modify" {
rm.applyImmediately = !rm.applyImmediately
return *m, nil
}
case "enter":
if rm.selected != nil && rm.confirmInput == confirmTarget {
m.screen = screenRDSDetail
Expand All @@ -218,6 +301,30 @@ func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd)
return *m, nil
}

func (rm rdsModel) loadClasses(m Model) tea.Cmd {
instance := *rm.selected
return func() tea.Msg {
ctx := context.Background()
repo := m.awsRepo
if repo == nil {
var err error
repo, err = awsservice.NewAwsRepository(ctx, m.cfg)
if err != nil {
return errMsg{err: err}
}
}

classes, err := repo.ListOrderableDBInstanceClasses(ctx, instance.Engine, instance.EngineVersion)
if err != nil {
return errMsg{err: err}
}
if len(classes) == 0 {
return errMsg{err: fmt.Errorf("no orderable instance classes found for %s %s in this region", instance.Engine, instance.EngineVersion)}
}
return rdsClassesLoadedMsg{instanceID: instance.DBInstanceID, classes: classes}
}
}

func (rm rdsModel) loadInstances(m Model) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
Expand All @@ -243,6 +350,8 @@ func (rm rdsModel) executeAction(m Model, action, dbInstanceID string) tea.Cmd {
if rm.selected != nil {
clusterID = rm.selected.ClusterID
}
pendingClass := rm.pendingClass
applyImmediately := rm.applyImmediately
return func() tea.Msg {
ctx := context.Background()
repo := m.awsRepo
Expand All @@ -255,6 +364,11 @@ func (rm rdsModel) executeAction(m Model, action, dbInstanceID string) tea.Cmd {
}

var err error
if action == "modify" {
// Class modification is always instance-level, even for cluster members.
err = repo.ModifyDBInstanceClass(ctx, dbInstanceID, pendingClass, applyImmediately)
return rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 수정 성공 후 기존 상태 폴링으로 넘기면 ApplyImmediately=false에서는 DB 상태가 계속 available이므로 첫 조회에서 폴링이 종료됩니다. 변경은 다음 유지보수 창에 예약됐는데 UI는 완료처럼 보이고 pending class도 표시하지 않습니다. immediate=true도 AWS가 아직 modifying으로 전환되기 전 첫 조회가 available이면 같은 조기 종료가 가능합니다. modify 결과는 PendingModifiedValues.DBInstanceClass를 모델에 담아 목표 class가 적용될 때까지 추적하거나, 적어도 deferred 요청은 폴링하지 않고 ‘예약됨’을 명시해 주세요.

}
if clusterID != "" {
// Aurora cluster-level actions
switch action {
Expand Down Expand Up @@ -434,6 +548,63 @@ func (rm rdsModel) viewDetail(m Model) string {
return b.String()
}

func (rm rdsModel) viewClassPicker(m Model) string {
var b strings.Builder
var panel strings.Builder
b.WriteString(m.renderStatusBar())
b.WriteString(titleStyle.Render("Select Instance Class"))
b.WriteString("\n")
if rm.selected != nil {
b.WriteString(dimStyle.Render(fmt.Sprintf(" Instance: %s current: %s engine: %s %s",
rm.selected.DBInstanceID, rm.selected.InstanceClass, rm.selected.Engine, rm.selected.EngineVersion)))
b.WriteString("\n")
}
if rm.classFiltering || rm.classFilter != "" {
b.WriteString(filterStyle.Render(fmt.Sprintf(" /%s▏", rm.classFilter)))
b.WriteString("\n")
}
b.WriteString("\n")

if len(rm.filteredClasses) == 0 {
emptyText := " No instance classes found"
if len(rm.classes) > 0 {
emptyText = " No matching instance classes"
}
panel.WriteString(dimStyle.Render(emptyText))
panel.WriteString("\n")
} else {
visibleLines := max(m.height-11, 5)
start := 0
if rm.classIdx >= visibleLines {
start = rm.classIdx - visibleLines + 1
}
end := min(start+visibleLines, len(rm.filteredClasses))
for i := start; i < end; i++ {
class := rm.filteredClasses[i]
cursor := " "
style := normalStyle
marker := ""
if rm.selected != nil && class == rm.selected.InstanceClass {
marker = " (current)"
}
if i == rm.classIdx {
cursor = "> "
style = selectedStyle
}
panel.WriteString(style.Render(cursor + class))
panel.WriteString(dimStyle.Render(marker))
panel.WriteString("\n")
}
panel.WriteString("\n")
panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d classes", len(rm.filteredClasses), len(rm.classes))))
}

b.WriteString(m.renderListPanel(panel.String()))
b.WriteString("\n\n")
b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: choose • esc: back"))
return b.String()
}

func (rm rdsModel) viewConfirm(m Model) string {
if rm.selected == nil {
return ""
Expand All @@ -459,6 +630,25 @@ func (rm rdsModel) viewConfirm(m Model) string {
b.WriteString("\n\n")
b.WriteString(normalStyle.Render(" [y] Yes [n] No"))
b.WriteString("\n")
} else if rm.action == "modify" {
b.WriteString(normalStyle.Render(" You are about to modify the instance class of:"))
b.WriteString("\n")
b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", r.DBInstanceID)))
b.WriteString("\n\n")
b.WriteString(m.renderEC2DetailLine("Current class", ec2ValueOrDash(r.InstanceClass)))
b.WriteString(m.renderEC2DetailLine("New class", ec2ValueOrDash(rm.pendingClass)))
applyLabel := "no (next maintenance window)"
if rm.applyImmediately {
applyLabel = "yes (may cause downtime now)"
}
b.WriteString(m.renderEC2DetailLine("Apply immediately", applyLabel))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" Type the instance identifier to confirm:"))
b.WriteString("\n")
b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", rm.confirmInput)))
b.WriteString("\n\n")
b.WriteString(m.renderHelpBar("tab: toggle apply immediately • enter: confirm • esc: back"))
b.WriteString("\n")
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" You are about to %s %s:", rm.action, targetLabel)))
b.WriteString("\n")
Expand Down
Loading
Loading