-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add RDS instance class modification #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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() | ||
|
|
@@ -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 | ||
|
|
@@ -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: | ||
|
|
@@ -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) | ||
|
|
@@ -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] | ||
| 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" { | ||
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
@@ -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 | ||
|
|
@@ -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} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] 수정 성공 후 기존 상태 폴링으로 넘기면 |
||
| } | ||
| if clusterID != "" { | ||
| // Aurora cluster-level actions | ||
| switch action { | ||
|
|
@@ -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 "" | ||
|
|
@@ -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") | ||
|
|
||
There was a problem hiding this comment.
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 렌더링만 확인하고 이 경로는 놓칩니다.