From b97df48be979e421a240e3d30937619f647d761f Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 26 Mar 2026 17:09:46 +0900 Subject: [PATCH] refactor: split app.go into per-screen files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract message types to messages.go - Extract styles, status bar, fitToHeight, viewLoading, viewError to styles.go - Extract EC2/SSM screen logic to screen_ec2.go - Extract VPC/Subnet screen logic to screen_vpc.go - Extract RDS screen logic to screen_rds.go - Extract Route53 screen logic to screen_route53.go - Extract context picker logic to screen_context.go - Reduce app.go from 2,127 to 563 lines (core Model, Update, View dispatch) Pure file split — no logic or interface changes. --- internal/app/app.go | 1808 +++----------------------------- internal/app/messages.go | 76 ++ internal/app/screen_context.go | 189 ++++ internal/app/screen_ec2.go | 183 ++++ internal/app/screen_rds.go | 409 ++++++++ internal/app/screen_route53.go | 385 +++++++ internal/app/screen_vpc.go | 302 ++++++ internal/app/styles.go | 90 ++ 8 files changed, 1756 insertions(+), 1686 deletions(-) create mode 100644 internal/app/messages.go create mode 100644 internal/app/screen_context.go create mode 100644 internal/app/screen_ec2.go create mode 100644 internal/app/screen_rds.go create mode 100644 internal/app/screen_route53.go create mode 100644 internal/app/screen_vpc.go create mode 100644 internal/app/styles.go diff --git a/internal/app/app.go b/internal/app/app.go index a34a7d6..97e8a13 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,12 +4,9 @@ import ( "context" "fmt" "strings" - "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "unic/internal/auth" "unic/internal/config" "unic/internal/domain" awsservice "unic/internal/services/aws" @@ -37,76 +34,6 @@ const ( screenError ) -// Messages for Bubbletea commands. -type instancesLoadedMsg struct { - instances []awsservice.EC2Instance -} - -type vpcsLoadedMsg struct { - vpcs []awsservice.VPC -} - -type subnetsLoadedMsg struct { - subnets []awsservice.Subnet -} - -type availableIPsLoadedMsg struct { - subnet awsservice.Subnet - ips []string -} - -type callerIdentityMsg struct { - identity *awsservice.CallerIdentity -} - -type contextsLoadedMsg struct { - contexts []config.ContextInfo -} - -type contextSwitchedMsg struct { - cfg *config.Config - identity *awsservice.CallerIdentity -} - -type ssoLoginDoneMsg struct { - err error -} - -type errMsg struct { - err error -} - -type ssmSessionDoneMsg struct { - err error -} - -type rdsInstancesLoadedMsg struct { - instances []awsservice.RDSInstance -} - -type rdsActionDoneMsg struct { - action string - instanceID string - err error -} - -type rdsStatusRefreshedMsg struct { - instance *awsservice.RDSInstance - err error -} - -type rdsTickMsg struct { - instanceID string -} - -type route53ZonesLoadedMsg struct { - zones []awsservice.HostedZone -} - -type route53RecordsLoadedMsg struct { - records []awsservice.DNSRecord -} - // Model is the root Bubbletea model. type Model struct { cfg *config.Config @@ -158,18 +85,18 @@ type Model struct { rdsPolling bool // Route53 browser state - route53Zones []awsservice.HostedZone - filteredRoute53Zones []awsservice.HostedZone - route53ZoneIdx int - route53ZoneFilter string - route53ZoneFilterActive bool - selectedRoute53Zone *awsservice.HostedZone - route53Records []awsservice.DNSRecord - filteredRoute53Records []awsservice.DNSRecord - route53RecordIdx int - route53RecordFilter string + route53Zones []awsservice.HostedZone + filteredRoute53Zones []awsservice.HostedZone + route53ZoneIdx int + route53ZoneFilter string + route53ZoneFilterActive bool + selectedRoute53Zone *awsservice.HostedZone + route53Records []awsservice.DNSRecord + filteredRoute53Records []awsservice.DNSRecord + route53RecordIdx int + route53RecordFilter string route53RecordFilterActive bool - selectedRoute53Record *awsservice.DNSRecord + selectedRoute53Record *awsservice.DNSRecord // Context picker configPath string @@ -179,12 +106,12 @@ type Model struct { pendingContextName string // Context add wizard - addStep int // 0=auth_type select, 1+=field input, -1=confirm - addAuthIdx int - addFields []fieldDef - addFieldIdx int - addInput string - addValues map[string]string + addStep int // 0=auth_type select, 1+=field input, -1=confirm + addAuthIdx int + addFields []fieldDef + addFieldIdx int + addInput string + addValues map[string]string // Caller identity (loaded at startup) callerIdentity *awsservice.CallerIdentity @@ -483,156 +410,6 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) updateInstanceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - // If filter is active, handle text input - if m.filterActive { - switch key { - case "esc": - m.filterActive = false - case "enter": - m.filterActive = false - case "backspace": - if len(m.filterInput) > 0 { - m.filterInput = m.filterInput[:len(m.filterInput)-1] - m.applyFilter() - } - default: - if len(key) == 1 { - m.filterInput += key - m.applyFilter() - } - } - return m, nil - } - - switch key { - case "q", "esc": - m.screen = screenFeatureList - m.filterInput = "" - m.filtered = m.instances - m.instIdx = 0 - case "up", "k": - if m.instIdx > 0 { - m.instIdx-- - } - case "down", "j": - if m.instIdx < len(m.filtered)-1 { - m.instIdx++ - } - case "/": - m.filterActive = true - case "enter": - if len(m.filtered) > 0 && m.instIdx < len(m.filtered) { - return m, m.startSSMSession(m.filtered[m.instIdx]) - } - } - return m, nil -} - -func (m Model) updateVPCList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.screen = screenFeatureList - m.vpcIdx = 0 - case "up", "k": - if m.vpcIdx > 0 { - m.vpcIdx-- - } - case "down", "j": - if m.vpcIdx < len(m.filteredVPCs)-1 { - m.vpcIdx++ - } - case "enter": - if len(m.filteredVPCs) > 0 && m.vpcIdx < len(m.filteredVPCs) { - selected := m.filteredVPCs[m.vpcIdx] - m.selectedVPC = &selected - m.screen = screenLoading - return m, m.loadSubnets(selected.VPCID) - } - } - return m, nil -} - -func (m Model) updateSubnetList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.screen = screenVPCList - m.subnetIdx = 0 - case "up", "k": - if m.subnetIdx > 0 { - m.subnetIdx-- - } - case "down", "j": - if m.subnetIdx < len(m.subnets)-1 { - m.subnetIdx++ - } - case "enter": - if len(m.subnets) > 0 && m.subnetIdx < len(m.subnets) { - selected := m.subnets[m.subnetIdx] - m.selectedSubnet = &selected - m.screen = screenLoading - return m, m.loadAvailableIPs(selected) - } - } - return m, nil -} - -func (m Model) updateSubnetDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - if m.ipFilterActive { - switch key { - case "esc", "enter": - m.ipFilterActive = false - case "backspace": - if len(m.ipFilter) > 0 { - m.ipFilter = m.ipFilter[:len(m.ipFilter)-1] - m.applyIPFilter() - } - default: - if len(key) == 1 { - m.ipFilter += key - m.applyIPFilter() - } - } - return m, nil - } - - switch key { - case "q", "esc": - m.screen = screenSubnetList - case "up", "k": - if m.ipScrollOffset > 0 { - m.ipScrollOffset-- - } - case "down", "j": - visibleLines := max(m.height-12, 5) - if m.ipScrollOffset < len(m.filteredIPs)-visibleLines { - m.ipScrollOffset++ - } - case "/": - m.ipFilterActive = true - } - return m, nil -} - -func (m *Model) applyIPFilter() { - if m.ipFilter == "" { - m.filteredIPs = m.availableIPs - } else { - var result []string - for _, ip := range m.availableIPs { - if strings.Contains(ip, m.ipFilter) { - result = append(result, ip) - } - } - m.filteredIPs = result - } - m.ipScrollOffset = 0 -} - func (m Model) updateError(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": @@ -644,922 +421,132 @@ func (m Model) updateError(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) updateRDSList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - if m.rdsFilterActive { - switch key { - case "esc": - m.rdsFilterActive = false - case "enter": - m.rdsFilterActive = false - case "backspace": - if len(m.rdsFilter) > 0 { - m.rdsFilter = m.rdsFilter[:len(m.rdsFilter)-1] - m.applyRDSFilter() - } - default: - if len(key) == 1 { - m.rdsFilter += key - m.applyRDSFilter() - } - } - return m, nil +// View renders the current screen. +func (m Model) View() string { + if m.quitting { + return "" } - switch key { - case "q", "esc": - m.screen = screenFeatureList - m.rdsFilter = "" - m.filteredRDS = m.rdsInstances - m.rdsIdx = 0 - case "up", "k": - if m.rdsIdx > 0 { - m.rdsIdx-- - } - case "down", "j": - if m.rdsIdx < len(m.filteredRDS)-1 { - m.rdsIdx++ - } - case "/": - m.rdsFilterActive = true - case "enter": - if len(m.filteredRDS) > 0 && m.rdsIdx < len(m.filteredRDS) { - selected := m.filteredRDS[m.rdsIdx] - m.selectedRDS = &selected - m.screen = screenRDSDetail - } + var v string + switch m.screen { + case screenServiceList: + v = m.viewServiceList() + case screenFeatureList: + v = m.viewFeatureList() + case screenInstanceList: + v = m.viewInstanceList() + case screenVPCList: + v = m.viewVPCList() + case screenSubnetList: + v = m.viewSubnetList() + case screenSubnetDetail: + v = m.viewSubnetDetail() + case screenRDSList: + v = m.viewRDSList() + case screenRDSDetail: + v = m.viewRDSDetail() + case screenRDSConfirm: + v = m.viewRDSConfirm() + case screenRoute53ZoneList: + v = m.viewRoute53ZoneList() + case screenRoute53RecordList: + v = m.viewRoute53RecordList() + case screenRoute53RecordDetail: + v = m.viewRoute53RecordDetail() + case screenContextPicker: + v = m.viewContextPicker() + case screenContextAdd: + v = m.viewContextAdd() + case screenLoading: + v = m.viewLoading() + case screenError: + v = m.viewError() } - return m, nil -} -func (m Model) updateRDSDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.rdsPolling = false - m.screen = screenRDSList - case "s": - if m.selectedRDS != nil && m.selectedRDS.CanStart() { - m.rdsAction = "start" - m.rdsConfirmInput = "" - m.screen = screenRDSConfirm - } - case "x": - if m.selectedRDS != nil && m.selectedRDS.CanStop() { - m.rdsAction = "stop" - m.rdsConfirmInput = "" - m.screen = screenRDSConfirm - } - case "f": - if m.selectedRDS != nil && m.selectedRDS.CanFailover() { - m.rdsAction = "failover" - m.rdsConfirmInput = "" - m.screen = screenRDSConfirm - } - case "r": - if m.selectedRDS != nil { - return m, m.pollRDSStatus(m.selectedRDS.DBInstanceID) - } - } - return m, nil + return m.fitToHeight(v) } -func (m Model) updateRDSConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Start action uses simple y/n confirmation - if m.rdsAction == "start" { - switch msg.String() { - case "y", "enter": - if m.selectedRDS != nil { - m.screen = screenRDSDetail - return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) - } - case "n", "esc": - m.screen = screenRDSDetail - } - return m, nil - } +func (m Model) viewServiceList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Select AWS Service")) + b.WriteString("\n\n") - // Stop/failover require typing the identifier to confirm - // For Aurora cluster members, confirm with cluster ID; for standalone, instance ID - confirmTarget := "" - if m.selectedRDS != nil { - if m.selectedRDS.IsClusterMember() { - confirmTarget = m.selectedRDS.ClusterID - } else { - confirmTarget = m.selectedRDS.DBInstanceID - } - } - switch msg.String() { - case "esc": - m.screen = screenRDSDetail - case "enter": - if m.selectedRDS != nil && m.rdsConfirmInput == confirmTarget { - m.screen = screenRDSDetail - return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) - } - case "backspace": - if len(m.rdsConfirmInput) > 0 { - m.rdsConfirmInput = m.rdsConfirmInput[:len(m.rdsConfirmInput)-1] - } - default: - if runes := msg.Runes; len(runes) > 0 { - m.rdsConfirmInput += string(runes) - } + // overhead: status bar (2 lines) + title (1) + blank (1) + blank (1) + footer (1) = 6 + visibleLines := max(m.height-6, 3) + start := 0 + if m.svcIdx >= visibleLines { + start = m.svcIdx - visibleLines + 1 } - return m, nil -} + end := min(start+visibleLines, len(m.services)) -func (m *Model) applyRDSFilter() { - if m.rdsFilter == "" { - m.filteredRDS = m.rdsInstances - } else { - query := strings.ToLower(m.rdsFilter) - var result []awsservice.RDSInstance - for _, inst := range m.rdsInstances { - if strings.Contains(inst.FilterText(), query) { - result = append(result, inst) - } + for i := start; i < end; i++ { + svc := m.services[i] + cursor := " " + style := normalStyle + if i == m.svcIdx { + cursor = "> " + style = selectedStyle } - m.filteredRDS = result + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, svc.Name))) + b.WriteString("\n") } - m.rdsIdx = 0 + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: context • q: quit")) + return b.String() } -func (m Model) updateRoute53ZoneList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() +func (m Model) viewFeatureList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + svcName := m.services[m.svcIdx].Name + b.WriteString(titleStyle.Render(fmt.Sprintf("%s > Select Feature", svcName))) + b.WriteString("\n\n") - if m.route53ZoneFilterActive { - switch key { - case "esc": - m.route53ZoneFilterActive = false - case "enter": - m.route53ZoneFilterActive = false - case "backspace": - if len(m.route53ZoneFilter) > 0 { - m.route53ZoneFilter = m.route53ZoneFilter[:len(m.route53ZoneFilter)-1] - m.applyRoute53ZoneFilter() + // Each selected item takes 2 lines (name + description), others take 1. + // overhead: status bar (2) + title (1) + blank (1) + blank (1) + footer (1) = 6 + visibleLines := max(m.height-6, 3) + start := 0 + // Count lines from start to cursor to determine if we need to scroll + linesFromStart := 0 + for i := 0; i <= m.featIdx && i < len(m.features); i++ { + linesFromStart++ + if i == m.featIdx { + linesFromStart++ // selected item has description line + } + } + if linesFromStart > visibleLines { + // Scroll forward: find start index that fits cursor in view + linesFromStart = 0 + for i := m.featIdx; i >= 0; i-- { + needed := 1 + if i == m.featIdx { + needed = 2 } - default: - if len(key) == 1 { - m.route53ZoneFilter += key - m.applyRoute53ZoneFilter() + if linesFromStart+needed > visibleLines { + start = i + 1 + break } + linesFromStart += needed } - return m, nil } - switch key { - case "q", "esc": - m.screen = screenFeatureList - m.route53ZoneFilter = "" - m.filteredRoute53Zones = m.route53Zones - m.route53ZoneIdx = 0 - case "up", "k": - if m.route53ZoneIdx > 0 { - m.route53ZoneIdx-- + linesUsed := 0 + for i := start; i < len(m.features); i++ { + feat := m.features[i] + needed := 1 + if i == m.featIdx { + needed = 2 } - case "down", "j": - if m.route53ZoneIdx < len(m.filteredRoute53Zones)-1 { - m.route53ZoneIdx++ + if linesUsed+needed > visibleLines { + break } - case "/": - m.route53ZoneFilterActive = true - case "enter": - if len(m.filteredRoute53Zones) > 0 && m.route53ZoneIdx < len(m.filteredRoute53Zones) { - selected := m.filteredRoute53Zones[m.route53ZoneIdx] - m.selectedRoute53Zone = &selected - m.screen = screenLoading - return m, m.loadRoute53Records(selected.ID) - } - } - return m, nil -} - -func (m Model) updateRoute53RecordList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - if m.route53RecordFilterActive { - switch key { - case "esc": - m.route53RecordFilterActive = false - case "enter": - m.route53RecordFilterActive = false - case "backspace": - if len(m.route53RecordFilter) > 0 { - m.route53RecordFilter = m.route53RecordFilter[:len(m.route53RecordFilter)-1] - m.applyRoute53RecordFilter() - } - default: - if len(key) == 1 { - m.route53RecordFilter += key - m.applyRoute53RecordFilter() - } - } - return m, nil - } - - switch key { - case "q", "esc": - m.screen = screenRoute53ZoneList - m.route53RecordFilter = "" - m.filteredRoute53Records = m.route53Records - m.route53RecordIdx = 0 - case "up", "k": - if m.route53RecordIdx > 0 { - m.route53RecordIdx-- - } - case "down", "j": - if m.route53RecordIdx < len(m.filteredRoute53Records)-1 { - m.route53RecordIdx++ - } - case "/": - m.route53RecordFilterActive = true - case "enter": - if len(m.filteredRoute53Records) > 0 && m.route53RecordIdx < len(m.filteredRoute53Records) { - selected := m.filteredRoute53Records[m.route53RecordIdx] - m.selectedRoute53Record = &selected - m.screen = screenRoute53RecordDetail - } - } - return m, nil -} - -func (m Model) updateRoute53RecordDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.screen = screenRoute53RecordList - } - return m, nil -} - -func (m *Model) applyRoute53ZoneFilter() { - if m.route53ZoneFilter == "" { - m.filteredRoute53Zones = m.route53Zones - } else { - query := strings.ToLower(m.route53ZoneFilter) - var result []awsservice.HostedZone - for _, zone := range m.route53Zones { - if strings.Contains(zone.FilterText(), query) { - result = append(result, zone) - } - } - m.filteredRoute53Zones = result - } - m.route53ZoneIdx = 0 -} - -func (m *Model) applyRoute53RecordFilter() { - if m.route53RecordFilter == "" { - m.filteredRoute53Records = m.route53Records - } else { - query := strings.ToLower(m.route53RecordFilter) - var result []awsservice.DNSRecord - for _, rec := range m.route53Records { - if strings.Contains(rec.FilterText(), query) { - result = append(result, rec) - } - } - m.filteredRoute53Records = result - } - m.route53RecordIdx = 0 -} - -func (m *Model) applyFilter() { - if m.filterInput == "" { - m.filtered = m.instances - } else { - query := strings.ToLower(m.filterInput) - var result []awsservice.EC2Instance - for _, inst := range m.instances { - if strings.Contains(inst.FilterText(), query) { - result = append(result, inst) - } - } - m.filtered = result - } - m.instIdx = 0 -} - -func (m Model) loadVPCs() tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - repo, err := awsservice.NewAwsRepository(ctx, m.cfg) - if err != nil { - return errMsg{err: err} - } - m.awsRepo = repo - - vpcs, err := repo.ListVPCs(ctx) - if err != nil { - return errMsg{err: err} - } - if len(vpcs) == 0 { - return errMsg{err: fmt.Errorf("no VPCs found")} - } - return vpcsLoadedMsg{vpcs: vpcs} - } -} - -func (m Model) loadSubnets(vpcID string) tea.Cmd { - 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} - } - } - - subnets, err := repo.ListSubnets(ctx, vpcID) - if err != nil { - return errMsg{err: err} - } - if len(subnets) == 0 { - return errMsg{err: fmt.Errorf("no subnets found in VPC %s", vpcID)} - } - return subnetsLoadedMsg{subnets: subnets} - } -} - -func (m Model) loadAvailableIPs(subnet awsservice.Subnet) tea.Cmd { - 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} - } - } - ips, err := repo.ListAvailableIPs(ctx, subnet.SubnetID, subnet.CIDR) - if err != nil { - return errMsg{err: err} - } - return availableIPsLoadedMsg{subnet: subnet, ips: ips} - } -} - -func (m Model) loadInstances() tea.Cmd { - return func() tea.Msg { - if err := awsservice.CheckPluginInstalled(); err != nil { - return errMsg{err: err} - } - - ctx := context.Background() - repo, err := awsservice.NewAwsRepository(ctx, m.cfg) - if err != nil { - return errMsg{err: err} - } - m.awsRepo = repo - - instances, err := repo.ListRunningInstances(ctx) - if err != nil { - return errMsg{err: err} - } - - if len(instances) == 0 { - return errMsg{err: fmt.Errorf("no running EC2 instances found")} - } - - return instancesLoadedMsg{instances: instances} - } -} - -func (m Model) loadRDSInstances() tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - repo, err := awsservice.NewAwsRepository(ctx, m.cfg) - if err != nil { - return errMsg{err: err} - } - m.awsRepo = repo - - instances, err := repo.ListDBInstances(ctx) - if err != nil { - return errMsg{err: err} - } - if len(instances) == 0 { - return errMsg{err: fmt.Errorf("no RDS instances found")} - } - return rdsInstancesLoadedMsg{instances: instances} - } -} - -func (m Model) loadRoute53Zones() tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - repo, err := awsservice.NewAwsRepository(ctx, m.cfg) - if err != nil { - return errMsg{err: err} - } - m.awsRepo = repo - - zones, err := repo.ListHostedZones(ctx) - if err != nil { - return errMsg{err: err} - } - if len(zones) == 0 { - return errMsg{err: fmt.Errorf("no hosted zones found")} - } - return route53ZonesLoadedMsg{zones: zones} - } -} - -func (m Model) loadRoute53Records(zoneID string) tea.Cmd { - 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} - } - } - - records, err := repo.ListResourceRecordSets(ctx, zoneID) - if err != nil { - return errMsg{err: err} - } - if len(records) == 0 { - return errMsg{err: fmt.Errorf("no records found in zone %s", zoneID)} - } - return route53RecordsLoadedMsg{records: records} - } -} - -func (m Model) executeRDSAction(action, dbInstanceID string) tea.Cmd { - clusterID := "" - if m.selectedRDS != nil { - clusterID = m.selectedRDS.ClusterID - } - 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 rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} - } - } - - var err error - if clusterID != "" { - // Aurora cluster-level actions - switch action { - case "start": - err = repo.StartDBCluster(ctx, clusterID) - case "stop": - err = repo.StopDBCluster(ctx, clusterID) - case "failover": - err = repo.FailoverDBCluster(ctx, clusterID) - } - } else { - // Standalone instance actions - switch action { - case "start": - err = repo.StartDBInstance(ctx, dbInstanceID) - case "stop": - err = repo.StopDBInstance(ctx, dbInstanceID) - case "failover": - err = repo.RebootDBInstance(ctx, dbInstanceID, true) - } - } - return rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} - } -} - -func (m Model) pollRDSStatus(dbInstanceID string) tea.Cmd { - 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 rdsStatusRefreshedMsg{err: err} - } - } - - inst, err := repo.DescribeDBInstance(ctx, dbInstanceID) - return rdsStatusRefreshedMsg{instance: inst, err: err} - } -} - -func (m Model) tickRDSPoll(dbInstanceID string) tea.Cmd { - return tea.Tick(5*time.Second, func(_ time.Time) tea.Msg { - return rdsTickMsg{instanceID: dbInstanceID} - }) -} - -func (m Model) startSSMSession(inst awsservice.EC2Instance) tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - - // Initialize AWS repo if needed - repo := m.awsRepo - if repo == nil { - var err error - repo, err = awsservice.NewAwsRepository(ctx, m.cfg) - if err != nil { - return errMsg{err: err} - } - } - - sess, endpoint, err := repo.StartSession(ctx, inst.InstanceID) - if err != nil { - return errMsg{err: err} - } - - cmd, err := awsservice.BuildPluginCommand(sess, repo.Region, repo.Profile, inst.InstanceID, endpoint) - if err != nil { - return errMsg{err: err} - } - - execCmd := tea.ExecProcess(cmd, func(err error) tea.Msg { - // Terminate session after plugin exits - if sess.SessionId != nil { - _ = repo.TerminateSession(context.Background(), *sess.SessionId) - } - return ssmSessionDoneMsg{err: err} - }) - return execCmd() - } -} - -// fitToHeight ensures the rendered output is exactly m.height lines. -// It pads short content with blank lines and truncates long content, -// keeping both the header (top) and footer (bottom) visible by trimming -// from the middle of the content area. -func (m Model) fitToHeight(s string) string { - if m.height <= 0 { - return s - } - lines := strings.Split(s, "\n") - // Remove trailing empty line if present (common from trailing \n) - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - if len(lines) <= m.height { - // Pad to exact height so the terminal doesn't shift - for len(lines) < m.height { - lines = append(lines, "") - } - return strings.Join(lines, "\n") - } - // Content overflows: keep first (height-2) lines + last 1 line (footer) - // with a "..." indicator - footerLines := 1 - headerLines := m.height - footerLines - 1 // -1 for the "..." line - if headerLines < 1 { - headerLines = 1 - } - result := make([]string, 0, m.height) - result = append(result, lines[:headerLines]...) - result = append(result, dimStyle.Render(" ...")) - result = append(result, lines[len(lines)-footerLines:]...) - return strings.Join(result, "\n") -} - -// View renders the current screen. -func (m Model) View() string { - if m.quitting { - return "" - } - - var v string - switch m.screen { - case screenServiceList: - v = m.viewServiceList() - case screenFeatureList: - v = m.viewFeatureList() - case screenInstanceList: - v = m.viewInstanceList() - case screenVPCList: - v = m.viewVPCList() - case screenSubnetList: - v = m.viewSubnetList() - case screenSubnetDetail: - v = m.viewSubnetDetail() - case screenRDSList: - v = m.viewRDSList() - case screenRDSDetail: - v = m.viewRDSDetail() - case screenRDSConfirm: - v = m.viewRDSConfirm() - case screenRoute53ZoneList: - v = m.viewRoute53ZoneList() - case screenRoute53RecordList: - v = m.viewRoute53RecordList() - case screenRoute53RecordDetail: - v = m.viewRoute53RecordDetail() - case screenContextPicker: - v = m.viewContextPicker() - case screenContextAdd: - v = m.viewContextAdd() - case screenLoading: - v = m.viewLoading() - case screenError: - v = m.viewError() - } - - return m.fitToHeight(v) -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) - normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) - filterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("236")) -) - -func (m Model) loadContexts() tea.Cmd { - return func() tea.Msg { - contexts, err := config.Contexts(m.configPath) - if err != nil || len(contexts) == 0 { - return contextsLoadedMsg{} - } - return contextsLoadedMsg{contexts: contexts} - } -} - -func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q": - m.quitting = true - return m, tea.Quit - case "esc": - // If we have a valid config (mid-session C key), go back. - // If initial launch, quit. - if m.cfg.ContextName != "" { - m.screen = m.ctxPrevScreen - } else { - m.quitting = true - return m, tea.Quit - } - case "up", "k": - if m.ctxIdx > 0 { - m.ctxIdx-- - } - case "down", "j": - if m.ctxIdx < len(m.ctxList)-1 { - m.ctxIdx++ - } - case "enter": - if len(m.ctxList) > 0 && m.ctxIdx < len(m.ctxList) { - selected := m.ctxList[m.ctxIdx] - m.pendingContextName = selected.Name - m.screen = screenLoading - return m, m.switchContext(selected.Name) - } - case "a": - m.addStep = 0 - m.addAuthIdx = 0 - m.addFields = nil - m.addFieldIdx = 0 - m.addInput = "" - m.addValues = make(map[string]string) - m.screen = screenContextAdd - } - return m, nil -} - -func (m Model) switchContext(name string) tea.Cmd { - return func() tea.Msg { - if err := config.SetCurrent(m.configPath, name); err != nil { - return errMsg{err: err} - } - - cfg, err := config.Load(nil, nil, m.configPath) - if err != nil { - return errMsg{err: err} - } - - // SSO needs interactive terminal — hand off via tea.ExecProcess - if cfg.AuthType == config.AuthTypeSSO { - cmd, cleanup, err := awsservice.BuildSSOLoginCmd(cfg) - if err != nil { - return errMsg{err: err} - } - return tea.ExecProcess(cmd, func(err error) tea.Msg { - cleanup() - return ssoLoginDoneMsg{err: err} - })() - } - - // Non-SSO: perform auth + finalize in one shot - return m.doFinalizeContextSwitch()() - } -} - -func (m Model) finalizeContextSwitch() tea.Cmd { - return m.doFinalizeContextSwitch() -} - -func (m Model) doFinalizeContextSwitch() tea.Cmd { - return func() tea.Msg { - cfg, err := config.Load(nil, nil, m.configPath) - if err != nil { - return errMsg{err: err} - } - - // Perform non-SSO auth action (credential check, assume role, etc.) - if cfg.AuthType != config.AuthTypeSSO { - if _, err := auth.PostSwitch(cfg); err != nil { - return errMsg{err: err} - } - } - - // Get caller identity with new credentials - ctx := context.Background() - var identity *awsservice.CallerIdentity - repo, err := awsservice.NewAwsRepository(ctx, cfg) - if err == nil { - identity, _ = repo.GetCallerIdentity(ctx) - } - - return contextSwitchedMsg{ - cfg: cfg, - identity: identity, - } - } -} - -func (m Model) viewContextPicker() string { - var b strings.Builder - b.WriteString(titleStyle.Render("Select Context")) - b.WriteString("\n\n") - - if len(m.ctxList) == 0 { - b.WriteString(normalStyle.Render(" No contexts defined.")) - b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" Press 'a' to add your first context.")) - b.WriteString("\n") - } else { - // Measure max widths for alignment - maxName, maxRegion := 4, 6 // "NAME", "REGION" - for _, ctx := range m.ctxList { - if len(ctx.Name) > maxName { - maxName = len(ctx.Name) - } - if len(ctx.Region) > maxRegion { - maxRegion = len(ctx.Region) - } - } - - nameCol := lipgloss.NewStyle().Width(maxName + 2) - regionCol := lipgloss.NewStyle().Width(maxRegion + 2) - - // Header - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + regionCol.Render("REGION") + "AUTH")) - b.WriteString("\n") - - // overhead: title (1) + blank (1) + table header (1) + blank (1) + footer (1) = 5 - visibleLines := max(m.height-5, 3) - start := 0 - if m.ctxIdx >= visibleLines { - start = m.ctxIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.ctxList)) - - for i := start; i < end; i++ { - ctx := m.ctxList[i] - cursor := " " - style := normalStyle - if i == m.ctxIdx { - cursor = "> " - style = selectedStyle - } - - row := cursor + nameCol.Inherit(style).Render(ctx.Name) + regionCol.Inherit(style).Render(ctx.Region) + style.Render(ctx.AuthType) - if ctx.Current { - row += dimStyle.Render(" *") - } - b.WriteString(row) - b.WriteString("\n") - } - } - - b.WriteString("\n") - if m.cfg.ContextName != "" { - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • a: add • esc: back • q: quit")) - } else { - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • a: add • q: quit")) - } - return b.String() -} - -func (m Model) renderStatusBar() string { - var parts []string - - if m.cfg.ContextName != "" { - parts = append(parts, fmt.Sprintf("[%s]", m.cfg.ContextName)) - } - parts = append(parts, fmt.Sprintf("region:%s", m.cfg.Region)) - if m.cfg.AuthType != "" { - parts = append(parts, fmt.Sprintf("auth:%s", m.cfg.AuthType)) - } - if m.callerIdentity != nil && m.callerIdentity.Account != "" { - parts = append(parts, fmt.Sprintf("account:%s", m.callerIdentity.Account)) - } - - bar := strings.Join(parts, " ") - if m.width > 0 { - if len(bar) < m.width { - bar += strings.Repeat(" ", m.width-len(bar)) - } - } - return statusBarStyle.Render(bar) + "\n\n" -} - -func (m Model) viewServiceList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("Select AWS Service")) - b.WriteString("\n\n") - - // overhead: status bar (2 lines) + title (1) + blank (1) + blank (1) + footer (1) = 6 - visibleLines := max(m.height-6, 3) - start := 0 - if m.svcIdx >= visibleLines { - start = m.svcIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.services)) - - for i := start; i < end; i++ { - svc := m.services[i] - cursor := " " - style := normalStyle - if i == m.svcIdx { - cursor = "> " - style = selectedStyle - } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, svc.Name))) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: context • q: quit")) - return b.String() -} - -func (m Model) viewFeatureList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - svcName := m.services[m.svcIdx].Name - b.WriteString(titleStyle.Render(fmt.Sprintf("%s > Select Feature", svcName))) - b.WriteString("\n\n") - - // Each selected item takes 2 lines (name + description), others take 1. - // overhead: status bar (2) + title (1) + blank (1) + blank (1) + footer (1) = 6 - visibleLines := max(m.height-6, 3) - start := 0 - // Count lines from start to cursor to determine if we need to scroll - linesFromStart := 0 - for i := 0; i <= m.featIdx && i < len(m.features); i++ { - linesFromStart++ - if i == m.featIdx { - linesFromStart++ // selected item has description line - } - } - if linesFromStart > visibleLines { - // Scroll forward: find start index that fits cursor in view - linesFromStart = 0 - for i := m.featIdx; i >= 0; i-- { - needed := 1 - if i == m.featIdx { - needed = 2 - } - if linesFromStart+needed > visibleLines { - start = i + 1 - break - } - linesFromStart += needed - } - } - - linesUsed := 0 - for i := start; i < len(m.features); i++ { - feat := m.features[i] - needed := 1 - if i == m.featIdx { - needed = 2 - } - if linesUsed+needed > visibleLines { - break - } - cursor := " " - style := normalStyle - if i == m.featIdx { - cursor = "> " - style = selectedStyle + cursor := " " + style := normalStyle + if i == m.featIdx { + cursor = "> " + style = selectedStyle } b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, feat.Kind))) b.WriteString("\n") @@ -1574,554 +561,3 @@ func (m Model) viewFeatureList() string { b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back")) return b.String() } - -func (m Model) viewInstanceList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("EC2 Instances (Running)")) - b.WriteString("\n") - - // Filter bar - if m.filterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.filterInput))) - } else if m.filterInput != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.filterInput))) - } - b.WriteString("\n\n") - - if len(m.filtered) == 0 { - b.WriteString(dimStyle.Render(" No matching instances")) - b.WriteString("\n") - } else { - // Calculate visible range for scrolling - visibleLines := max(m.height-8, 5) - start := 0 - if m.instIdx >= visibleLines { - start = m.instIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.filtered)) - - for i := start; i < end; i++ { - inst := m.filtered[i] - cursor := " " - style := normalStyle - if i == m.instIdx { - cursor = "> " - style = selectedStyle - } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filtered), len(m.instances)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: connect • esc: back • H: home")) - return b.String() -} - -func (m Model) viewLoading() string { - return titleStyle.Render("Loading...") -} - -func (m Model) viewError() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(errorStyle.Render("Error")) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(m.errMsg)) - b.WriteString("\n\n") - b.WriteString(dimStyle.Render("enter/esc: go back • q: quit")) - return b.String() -} - -func (m Model) viewVPCList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("VPCs")) - b.WriteString("\n\n") - - if len(m.filteredVPCs) == 0 { - b.WriteString(dimStyle.Render(" No VPCs found")) - b.WriteString("\n") - } else { - visibleLines := max(m.height-8, 5) - start := 0 - if m.vpcIdx >= visibleLines { - start = m.vpcIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.filteredVPCs)) - - for i := start; i < end; i++ { - vpc := m.filteredVPCs[i] - cursor := " " - style := normalStyle - if i == m.vpcIdx { - cursor = "> " - style = selectedStyle - } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, vpc.DisplayTitle()))) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d VPCs", len(m.filteredVPCs)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back • H: home")) - return b.String() -} - -func (m Model) viewSubnetList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - vpcName := "" - if m.selectedVPC != nil { - vpcName = fmt.Sprintf(" (%s)", m.selectedVPC.Name) - } - b.WriteString(titleStyle.Render(fmt.Sprintf("Subnets%s", vpcName))) - b.WriteString("\n\n") - - if len(m.subnets) == 0 { - b.WriteString(dimStyle.Render(" No subnets found")) - b.WriteString("\n") - } else { - visibleLines := max(m.height-8, 5) - start := 0 - if m.subnetIdx >= visibleLines { - start = m.subnetIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.subnets)) - - for i := start; i < end; i++ { - s := m.subnets[i] - cursor := " " - style := normalStyle - if i == m.subnetIdx { - cursor = "> " - style = selectedStyle - } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d subnets", len(m.subnets)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: detail • esc: back • H: home")) - return b.String() -} - -func (m Model) viewSubnetDetail() string { - if m.selectedSubnet == nil { - return "" - } - s := m.selectedSubnet - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("Subnet Detail")) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Subnet ID : %s", s.SubnetID))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Name : %s", s.Name))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" CIDR : %s", s.CIDR))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" AZ : %s", s.AvailabilityZone))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Available IPs : %d", len(m.availableIPs)))) - b.WriteString("\n\n") - - // Filter bar - if m.ipFilterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.ipFilter))) - } else if m.ipFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.ipFilter))) - } - b.WriteString("\n") - - if len(m.filteredIPs) == 0 { - b.WriteString(dimStyle.Render(" No matching IPs")) - b.WriteString("\n") - } else { - visibleLines := max(m.height-14, 5) - start := m.ipScrollOffset - end := min(start+visibleLines, len(m.filteredIPs)) - - for _, ip := range m.filteredIPs[start:end] { - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", ip))) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d-%d of %d IPs", start+1, end, len(m.filteredIPs)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: scroll • /: filter • esc: back • H: home")) - return b.String() -} - -func (m Model) viewRDSList() string { - var b strings.Builder - b.WriteString(titleStyle.Render("RDS Instances")) - b.WriteString("\n") - - // Filter bar - if m.rdsFilterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.rdsFilter))) - } else if m.rdsFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.rdsFilter))) - } - b.WriteString("\n\n") - - if len(m.filteredRDS) == 0 { - b.WriteString(dimStyle.Render(" No matching instances")) - b.WriteString("\n") - } else { - visibleLines := max(m.height-8, 5) - start := 0 - if m.rdsIdx >= visibleLines { - start = m.rdsIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.filteredRDS)) - - for i := start; i < end; i++ { - inst := m.filteredRDS[i] - cursor := " " - style := normalStyle - if i == m.rdsIdx { - cursor = "> " - style = selectedStyle - } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filteredRDS), len(m.rdsInstances)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) - return b.String() -} - -func (m Model) viewRDSDetail() string { - if m.selectedRDS == nil { - return "" - } - r := m.selectedRDS - var b strings.Builder - b.WriteString(titleStyle.Render("RDS Instance Detail")) - b.WriteString("\n\n") - - b.WriteString(normalStyle.Render(fmt.Sprintf(" Identifier : %s", r.DBInstanceID))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Engine : %s %s", r.Engine, r.EngineVersion))) - b.WriteString("\n") - - // Color-code status - statusStr := r.Status - if r.Status == "available" { - statusStr = selectedStyle.Render(r.Status) - } else if awsservice.IsTransitionalStatus(r.Status) { - statusStr = filterStyle.Render(r.Status) - } else if r.Status == "stopped" || r.Status == "failed" { - statusStr = errorStyle.Render(r.Status) - } - pollingIndicator := "" - if m.rdsPolling { - pollingIndicator = filterStyle.Render(" (polling...)") - } - b.WriteString(fmt.Sprintf(" Status : %s%s", statusStr, pollingIndicator)) - b.WriteString("\n") - - b.WriteString(normalStyle.Render(fmt.Sprintf(" Class : %s", r.InstanceClass))) - b.WriteString("\n") - multiAZStr := "No" - if r.MultiAZ { - multiAZStr = "Yes" - } - b.WriteString(normalStyle.Render(fmt.Sprintf(" Multi-AZ : %s", multiAZStr))) - b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Storage : %d GB", r.StorageGB))) - b.WriteString("\n") - endpoint := r.Endpoint - if endpoint == "" { - endpoint = dimStyle.Render("(unavailable)") - } - b.WriteString(normalStyle.Render(fmt.Sprintf(" Endpoint : %s", endpoint))) - b.WriteString("\n") - if r.ClusterID != "" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" Cluster : %s", r.ClusterID))) - b.WriteString("\n") - } - - b.WriteString("\n") - suffix := "" - if r.IsClusterMember() { - suffix = " Cluster" - } - b.WriteString(titleStyle.Render("Actions")) - b.WriteString("\n") - if r.CanStart() { - b.WriteString(normalStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) - b.WriteString("\n") - } else { - b.WriteString(dimStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) - b.WriteString("\n") - } - if r.CanStop() { - b.WriteString(normalStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) - b.WriteString("\n") - } else { - b.WriteString(dimStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) - b.WriteString("\n") - } - if r.CanFailover() { - b.WriteString(normalStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) - b.WriteString("\n") - } else { - b.WriteString(dimStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) - b.WriteString("\n") - } - b.WriteString(normalStyle.Render(" [r] Refresh")) - b.WriteString("\n") - - b.WriteString("\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) - return b.String() -} - -func (m Model) viewRoute53ZoneList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("Route53 Hosted Zones")) - b.WriteString("\n") - - if m.route53ZoneFilterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53ZoneFilter))) - } else if m.route53ZoneFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53ZoneFilter))) - } - b.WriteString("\n\n") - - if len(m.filteredRoute53Zones) == 0 { - b.WriteString(dimStyle.Render(" No matching hosted zones")) - b.WriteString("\n") - } else { - // Measure max widths for column alignment - maxName, maxID := 4, 2 // "NAME", "ID" - for _, zone := range m.filteredRoute53Zones { - if len(zone.Name) > maxName { - maxName = len(zone.Name) - } - if len(zone.ID) > maxID { - maxID = len(zone.ID) - } - } - nameCol := lipgloss.NewStyle().Width(maxName + 2) - idCol := lipgloss.NewStyle().Width(maxID + 2) - recordsCol := lipgloss.NewStyle().Width(9) // "RECORDS" + padding - - // Header - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + idCol.Render("ID") + recordsCol.Render("RECORDS") + "TYPE")) - b.WriteString("\n") - - visibleLines := max(m.height-9, 5) - start := 0 - if m.route53ZoneIdx >= visibleLines { - start = m.route53ZoneIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.filteredRoute53Zones)) - - for i := start; i < end; i++ { - zone := m.filteredRoute53Zones[i] - cursor := " " - style := normalStyle - if i == m.route53ZoneIdx { - cursor = "> " - style = selectedStyle - } - zoneType := "Public" - if zone.IsPrivate { - zoneType = "Private" - } - row := cursor + - nameCol.Inherit(style).Render(zone.Name) + - idCol.Inherit(dimStyle).Render(zone.ID) + - recordsCol.Inherit(dimStyle).Render(fmt.Sprintf("%d", zone.ResourceRecordCount)) + - dimStyle.Render(zoneType) - b.WriteString(row) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d zones", len(m.filteredRoute53Zones), len(m.route53Zones)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: records • esc: back • H: home")) - return b.String() -} - -func (m Model) viewRoute53RecordList() string { - var b strings.Builder - b.WriteString(m.renderStatusBar()) - zoneName := "" - if m.selectedRoute53Zone != nil { - zoneName = m.selectedRoute53Zone.Name - } - b.WriteString(titleStyle.Render(fmt.Sprintf("DNS Records — %s", zoneName))) - b.WriteString("\n") - - if m.route53RecordFilterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53RecordFilter))) - } else if m.route53RecordFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53RecordFilter))) - } - b.WriteString("\n\n") - - if len(m.filteredRoute53Records) == 0 { - b.WriteString(dimStyle.Render(" No matching records")) - b.WriteString("\n") - } else { - // Measure max widths for column alignment - maxName, maxType := 4, 4 // "NAME", "TYPE" - for _, rec := range m.filteredRoute53Records { - if len(rec.Name) > maxName { - maxName = len(rec.Name) - } - if len(rec.Type) > maxType { - maxType = len(rec.Type) - } - } - nameCol := lipgloss.NewStyle().Width(maxName + 2) - typeCol := lipgloss.NewStyle().Width(maxType + 2) - - // Header - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + typeCol.Render("TYPE") + "VALUE")) - b.WriteString("\n") - - visibleLines := max(m.height-9, 5) - start := 0 - if m.route53RecordIdx >= visibleLines { - start = m.route53RecordIdx - visibleLines + 1 - } - end := min(start+visibleLines, len(m.filteredRoute53Records)) - - for i := start; i < end; i++ { - rec := m.filteredRoute53Records[i] - cursor := " " - style := normalStyle - if i == m.route53RecordIdx { - cursor = "> " - style = selectedStyle - } - // Build value string - valStr := "" - if rec.AliasTarget != "" { - valStr = "ALIAS → " + rec.AliasTarget - } else { - valStr = strings.Join(rec.Values, ", ") - } - if len(valStr) > 60 { - valStr = valStr[:57] + "..." - } - row := cursor + - nameCol.Inherit(style).Render(rec.Name) + - typeCol.Inherit(filterStyle).Render(rec.Type) + - dimStyle.Render(valStr) - b.WriteString(row) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d records", len(m.filteredRoute53Records), len(m.route53Records)))) - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) - return b.String() -} - -func (m Model) viewRoute53RecordDetail() string { - if m.selectedRoute53Record == nil { - return "" - } - r := m.selectedRoute53Record - var b strings.Builder - b.WriteString(m.renderStatusBar()) - b.WriteString(titleStyle.Render("DNS Record Detail")) - b.WriteString("\n\n") - - labelStyle := dimStyle.Width(10) - - b.WriteString(" " + labelStyle.Render("Name") + normalStyle.Render(r.Name)) - b.WriteString("\n") - b.WriteString(" " + labelStyle.Render("Type") + filterStyle.Render(r.Type)) - b.WriteString("\n") - - if r.AliasTarget != "" { - b.WriteString(" " + labelStyle.Render("Alias") + normalStyle.Render(r.AliasTarget)) - b.WriteString("\n") - } else { - b.WriteString(" " + labelStyle.Render("TTL") + normalStyle.Render(fmt.Sprintf("%d", r.TTL))) - b.WriteString("\n") - } - - if len(r.Values) > 0 { - b.WriteString(" " + labelStyle.Render("Values")) - b.WriteString("\n") - for _, v := range r.Values { - b.WriteString(" " + labelStyle.Render("") + normalStyle.Render(v)) - b.WriteString("\n") - } - } - - b.WriteString("\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) - return b.String() -} - -func (m Model) viewRDSConfirm() string { - if m.selectedRDS == nil { - return "" - } - r := m.selectedRDS - - // For Aurora cluster members, show cluster-level info - targetLabel := "instance" - targetID := r.DBInstanceID - if r.IsClusterMember() { - targetLabel = "cluster" - targetID = r.ClusterID - } - - var b strings.Builder - b.WriteString(errorStyle.Render("Confirm Action")) - b.WriteString("\n\n") - - if m.rdsAction == "start" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" Are you sure you want to start %s %s?", - targetLabel, targetID))) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(" [y] Yes [n] No")) - b.WriteString("\n") - } else { - b.WriteString(normalStyle.Render(fmt.Sprintf(" You are about to %s %s:", m.rdsAction, targetLabel))) - b.WriteString("\n") - b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", targetID))) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Type the %s identifier to confirm:", targetLabel))) - b.WriteString("\n") - b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.rdsConfirmInput))) - b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) - b.WriteString("\n") - } - return b.String() -} diff --git a/internal/app/messages.go b/internal/app/messages.go new file mode 100644 index 0000000..6d291f6 --- /dev/null +++ b/internal/app/messages.go @@ -0,0 +1,76 @@ +package app + +import ( + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +// Messages for Bubbletea commands. +type instancesLoadedMsg struct { + instances []awsservice.EC2Instance +} + +type vpcsLoadedMsg struct { + vpcs []awsservice.VPC +} + +type subnetsLoadedMsg struct { + subnets []awsservice.Subnet +} + +type availableIPsLoadedMsg struct { + subnet awsservice.Subnet + ips []string +} + +type callerIdentityMsg struct { + identity *awsservice.CallerIdentity +} + +type contextsLoadedMsg struct { + contexts []config.ContextInfo +} + +type contextSwitchedMsg struct { + cfg *config.Config + identity *awsservice.CallerIdentity +} + +type ssoLoginDoneMsg struct { + err error +} + +type errMsg struct { + err error +} + +type ssmSessionDoneMsg struct { + err error +} + +type rdsInstancesLoadedMsg struct { + instances []awsservice.RDSInstance +} + +type rdsActionDoneMsg struct { + action string + instanceID string + err error +} + +type rdsStatusRefreshedMsg struct { + instance *awsservice.RDSInstance + err error +} + +type rdsTickMsg struct { + instanceID string +} + +type route53ZonesLoadedMsg struct { + zones []awsservice.HostedZone +} + +type route53RecordsLoadedMsg struct { + records []awsservice.DNSRecord +} diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go new file mode 100644 index 0000000..31a3c10 --- /dev/null +++ b/internal/app/screen_context.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "unic/internal/auth" + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +func (m Model) loadContexts() tea.Cmd { + return func() tea.Msg { + contexts, err := config.Contexts(m.configPath) + if err != nil || len(contexts) == 0 { + return contextsLoadedMsg{} + } + return contextsLoadedMsg{contexts: contexts} + } +} + +func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + // If we have a valid config (mid-session C key), go back. + // If initial launch, quit. + if m.cfg.ContextName != "" { + m.screen = m.ctxPrevScreen + } else { + m.quitting = true + return m, tea.Quit + } + case "up", "k": + if m.ctxIdx > 0 { + m.ctxIdx-- + } + case "down", "j": + if m.ctxIdx < len(m.ctxList)-1 { + m.ctxIdx++ + } + case "enter": + if len(m.ctxList) > 0 && m.ctxIdx < len(m.ctxList) { + selected := m.ctxList[m.ctxIdx] + m.pendingContextName = selected.Name + m.screen = screenLoading + return m, m.switchContext(selected.Name) + } + case "a": + m.addStep = 0 + m.addAuthIdx = 0 + m.addFields = nil + m.addFieldIdx = 0 + m.addInput = "" + m.addValues = make(map[string]string) + m.screen = screenContextAdd + } + return m, nil +} + +func (m Model) switchContext(name string) tea.Cmd { + return func() tea.Msg { + if err := config.SetCurrent(m.configPath, name); err != nil { + return errMsg{err: err} + } + + cfg, err := config.Load(nil, nil, m.configPath) + if err != nil { + return errMsg{err: err} + } + + // SSO needs interactive terminal — hand off via tea.ExecProcess + if cfg.AuthType == config.AuthTypeSSO { + cmd, cleanup, err := awsservice.BuildSSOLoginCmd(cfg) + if err != nil { + return errMsg{err: err} + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { + cleanup() + return ssoLoginDoneMsg{err: err} + })() + } + + // Non-SSO: perform auth + finalize in one shot + return m.doFinalizeContextSwitch()() + } +} + +func (m Model) finalizeContextSwitch() tea.Cmd { + return m.doFinalizeContextSwitch() +} + +func (m Model) doFinalizeContextSwitch() tea.Cmd { + return func() tea.Msg { + cfg, err := config.Load(nil, nil, m.configPath) + if err != nil { + return errMsg{err: err} + } + + // Perform non-SSO auth action (credential check, assume role, etc.) + if cfg.AuthType != config.AuthTypeSSO { + if _, err := auth.PostSwitch(cfg); err != nil { + return errMsg{err: err} + } + } + + // Get caller identity with new credentials + ctx := context.Background() + var identity *awsservice.CallerIdentity + repo, err := awsservice.NewAwsRepository(ctx, cfg) + if err == nil { + identity, _ = repo.GetCallerIdentity(ctx) + } + + return contextSwitchedMsg{ + cfg: cfg, + identity: identity, + } + } +} + +func (m Model) viewContextPicker() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Select Context")) + b.WriteString("\n\n") + + if len(m.ctxList) == 0 { + b.WriteString(normalStyle.Render(" No contexts defined.")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Press 'a' to add your first context.")) + b.WriteString("\n") + } else { + // Measure max widths for alignment + maxName, maxRegion := 4, 6 // "NAME", "REGION" + for _, ctx := range m.ctxList { + if len(ctx.Name) > maxName { + maxName = len(ctx.Name) + } + if len(ctx.Region) > maxRegion { + maxRegion = len(ctx.Region) + } + } + + nameCol := lipgloss.NewStyle().Width(maxName + 2) + regionCol := lipgloss.NewStyle().Width(maxRegion + 2) + + // Header + b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + regionCol.Render("REGION") + "AUTH")) + b.WriteString("\n") + + // overhead: title (1) + blank (1) + table header (1) + blank (1) + footer (1) = 5 + visibleLines := max(m.height-5, 3) + start := 0 + if m.ctxIdx >= visibleLines { + start = m.ctxIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.ctxList)) + + for i := start; i < end; i++ { + ctx := m.ctxList[i] + cursor := " " + style := normalStyle + if i == m.ctxIdx { + cursor = "> " + style = selectedStyle + } + + row := cursor + nameCol.Inherit(style).Render(ctx.Name) + regionCol.Inherit(style).Render(ctx.Region) + style.Render(ctx.AuthType) + if ctx.Current { + row += dimStyle.Render(" *") + } + b.WriteString(row) + b.WriteString("\n") + } + } + + b.WriteString("\n") + if m.cfg.ContextName != "" { + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • a: add • esc: back • q: quit")) + } else { + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • a: add • q: quit")) + } + return b.String() +} diff --git a/internal/app/screen_ec2.go b/internal/app/screen_ec2.go new file mode 100644 index 0000000..219807a --- /dev/null +++ b/internal/app/screen_ec2.go @@ -0,0 +1,183 @@ +package app + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +func (m Model) updateInstanceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // If filter is active, handle text input + if m.filterActive { + switch key { + case "esc": + m.filterActive = false + case "enter": + m.filterActive = false + case "backspace": + if len(m.filterInput) > 0 { + m.filterInput = m.filterInput[:len(m.filterInput)-1] + m.applyFilter() + } + default: + if len(key) == 1 { + m.filterInput += key + m.applyFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.filterInput = "" + m.filtered = m.instances + m.instIdx = 0 + case "up", "k": + if m.instIdx > 0 { + m.instIdx-- + } + case "down", "j": + if m.instIdx < len(m.filtered)-1 { + m.instIdx++ + } + case "/": + m.filterActive = true + case "enter": + if len(m.filtered) > 0 && m.instIdx < len(m.filtered) { + return m, m.startSSMSession(m.filtered[m.instIdx]) + } + } + return m, nil +} + +func (m *Model) applyFilter() { + if m.filterInput == "" { + m.filtered = m.instances + } else { + query := strings.ToLower(m.filterInput) + var result []awsservice.EC2Instance + for _, inst := range m.instances { + if strings.Contains(inst.FilterText(), query) { + result = append(result, inst) + } + } + m.filtered = result + } + m.instIdx = 0 +} + +func (m Model) loadInstances() tea.Cmd { + return func() tea.Msg { + if err := awsservice.CheckPluginInstalled(); err != nil { + return errMsg{err: err} + } + + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + instances, err := repo.ListRunningInstances(ctx) + if err != nil { + return errMsg{err: err} + } + + if len(instances) == 0 { + return errMsg{err: fmt.Errorf("no running EC2 instances found")} + } + + return instancesLoadedMsg{instances: instances} + } +} + +func (m Model) startSSMSession(inst awsservice.EC2Instance) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + // Initialize AWS repo if needed + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + } + + sess, endpoint, err := repo.StartSession(ctx, inst.InstanceID) + if err != nil { + return errMsg{err: err} + } + + cmd, err := awsservice.BuildPluginCommand(sess, repo.Region, repo.Profile, inst.InstanceID, endpoint) + if err != nil { + return errMsg{err: err} + } + + execCmd := tea.ExecProcess(cmd, func(err error) tea.Msg { + // Terminate session after plugin exits + if sess.SessionId != nil { + _ = repo.TerminateSession(context.Background(), *sess.SessionId) + } + return ssmSessionDoneMsg{err: err} + }) + return execCmd() + } +} + +func (m Model) viewInstanceList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("EC2 Instances (Running)")) + b.WriteString("\n") + + // Filter bar + if m.filterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.filterInput))) + } else if m.filterInput != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.filterInput))) + } + b.WriteString("\n\n") + + if len(m.filtered) == 0 { + b.WriteString(dimStyle.Render(" No matching instances")) + b.WriteString("\n") + } else { + // Calculate visible range for scrolling + visibleLines := max(m.height-8, 5) + start := 0 + if m.instIdx >= visibleLines { + start = m.instIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filtered)) + + for i := start; i < end; i++ { + inst := m.filtered[i] + cursor := " " + style := normalStyle + if i == m.instIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filtered), len(m.instances)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: connect • esc: back • H: home")) + return b.String() +} diff --git a/internal/app/screen_rds.go b/internal/app/screen_rds.go new file mode 100644 index 0000000..5cf381f --- /dev/null +++ b/internal/app/screen_rds.go @@ -0,0 +1,409 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +func (m Model) updateRDSList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.rdsFilterActive { + switch key { + case "esc": + m.rdsFilterActive = false + case "enter": + m.rdsFilterActive = false + case "backspace": + if len(m.rdsFilter) > 0 { + m.rdsFilter = m.rdsFilter[:len(m.rdsFilter)-1] + m.applyRDSFilter() + } + default: + if len(key) == 1 { + m.rdsFilter += key + m.applyRDSFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.rdsFilter = "" + m.filteredRDS = m.rdsInstances + m.rdsIdx = 0 + case "up", "k": + if m.rdsIdx > 0 { + m.rdsIdx-- + } + case "down", "j": + if m.rdsIdx < len(m.filteredRDS)-1 { + m.rdsIdx++ + } + case "/": + m.rdsFilterActive = true + case "enter": + if len(m.filteredRDS) > 0 && m.rdsIdx < len(m.filteredRDS) { + selected := m.filteredRDS[m.rdsIdx] + m.selectedRDS = &selected + m.screen = screenRDSDetail + } + } + return m, nil +} + +func (m Model) updateRDSDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.rdsPolling = false + m.screen = screenRDSList + case "s": + if m.selectedRDS != nil && m.selectedRDS.CanStart() { + m.rdsAction = "start" + m.rdsConfirmInput = "" + m.screen = screenRDSConfirm + } + case "x": + if m.selectedRDS != nil && m.selectedRDS.CanStop() { + m.rdsAction = "stop" + m.rdsConfirmInput = "" + m.screen = screenRDSConfirm + } + case "f": + if m.selectedRDS != nil && m.selectedRDS.CanFailover() { + m.rdsAction = "failover" + m.rdsConfirmInput = "" + m.screen = screenRDSConfirm + } + case "r": + if m.selectedRDS != nil { + return m, m.pollRDSStatus(m.selectedRDS.DBInstanceID) + } + } + return m, nil +} + +func (m Model) updateRDSConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Start action uses simple y/n confirmation + if m.rdsAction == "start" { + switch msg.String() { + case "y", "enter": + if m.selectedRDS != nil { + m.screen = screenRDSDetail + return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) + } + case "n", "esc": + m.screen = screenRDSDetail + } + return m, nil + } + + // Stop/failover require typing the identifier to confirm + // For Aurora cluster members, confirm with cluster ID; for standalone, instance ID + confirmTarget := "" + if m.selectedRDS != nil { + if m.selectedRDS.IsClusterMember() { + confirmTarget = m.selectedRDS.ClusterID + } else { + confirmTarget = m.selectedRDS.DBInstanceID + } + } + switch msg.String() { + case "esc": + m.screen = screenRDSDetail + case "enter": + if m.selectedRDS != nil && m.rdsConfirmInput == confirmTarget { + m.screen = screenRDSDetail + return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) + } + case "backspace": + if len(m.rdsConfirmInput) > 0 { + m.rdsConfirmInput = m.rdsConfirmInput[:len(m.rdsConfirmInput)-1] + } + default: + if runes := msg.Runes; len(runes) > 0 { + m.rdsConfirmInput += string(runes) + } + } + return m, nil +} + +func (m *Model) applyRDSFilter() { + if m.rdsFilter == "" { + m.filteredRDS = m.rdsInstances + } else { + query := strings.ToLower(m.rdsFilter) + var result []awsservice.RDSInstance + for _, inst := range m.rdsInstances { + if strings.Contains(inst.FilterText(), query) { + result = append(result, inst) + } + } + m.filteredRDS = result + } + m.rdsIdx = 0 +} + +func (m Model) loadRDSInstances() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + instances, err := repo.ListDBInstances(ctx) + if err != nil { + return errMsg{err: err} + } + if len(instances) == 0 { + return errMsg{err: fmt.Errorf("no RDS instances found")} + } + return rdsInstancesLoadedMsg{instances: instances} + } +} + +func (m Model) executeRDSAction(action, dbInstanceID string) tea.Cmd { + clusterID := "" + if m.selectedRDS != nil { + clusterID = m.selectedRDS.ClusterID + } + 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 rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} + } + } + + var err error + if clusterID != "" { + // Aurora cluster-level actions + switch action { + case "start": + err = repo.StartDBCluster(ctx, clusterID) + case "stop": + err = repo.StopDBCluster(ctx, clusterID) + case "failover": + err = repo.FailoverDBCluster(ctx, clusterID) + } + } else { + // Standalone instance actions + switch action { + case "start": + err = repo.StartDBInstance(ctx, dbInstanceID) + case "stop": + err = repo.StopDBInstance(ctx, dbInstanceID) + case "failover": + err = repo.RebootDBInstance(ctx, dbInstanceID, true) + } + } + return rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} + } +} + +func (m Model) pollRDSStatus(dbInstanceID string) tea.Cmd { + 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 rdsStatusRefreshedMsg{err: err} + } + } + + inst, err := repo.DescribeDBInstance(ctx, dbInstanceID) + return rdsStatusRefreshedMsg{instance: inst, err: err} + } +} + +func (m Model) tickRDSPoll(dbInstanceID string) tea.Cmd { + return tea.Tick(5*time.Second, func(_ time.Time) tea.Msg { + return rdsTickMsg{instanceID: dbInstanceID} + }) +} + +func (m Model) viewRDSList() string { + var b strings.Builder + b.WriteString(titleStyle.Render("RDS Instances")) + b.WriteString("\n") + + // Filter bar + if m.rdsFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.rdsFilter))) + } else if m.rdsFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.rdsFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredRDS) == 0 { + b.WriteString(dimStyle.Render(" No matching instances")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.rdsIdx >= visibleLines { + start = m.rdsIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredRDS)) + + for i := start; i < end; i++ { + inst := m.filteredRDS[i] + cursor := " " + style := normalStyle + if i == m.rdsIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filteredRDS), len(m.rdsInstances)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewRDSDetail() string { + if m.selectedRDS == nil { + return "" + } + r := m.selectedRDS + var b strings.Builder + b.WriteString(titleStyle.Render("RDS Instance Detail")) + b.WriteString("\n\n") + + b.WriteString(normalStyle.Render(fmt.Sprintf(" Identifier : %s", r.DBInstanceID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Engine : %s %s", r.Engine, r.EngineVersion))) + b.WriteString("\n") + + // Color-code status + statusStr := r.Status + if r.Status == "available" { + statusStr = selectedStyle.Render(r.Status) + } else if awsservice.IsTransitionalStatus(r.Status) { + statusStr = filterStyle.Render(r.Status) + } else if r.Status == "stopped" || r.Status == "failed" { + statusStr = errorStyle.Render(r.Status) + } + pollingIndicator := "" + if m.rdsPolling { + pollingIndicator = filterStyle.Render(" (polling...)") + } + b.WriteString(fmt.Sprintf(" Status : %s%s", statusStr, pollingIndicator)) + b.WriteString("\n") + + b.WriteString(normalStyle.Render(fmt.Sprintf(" Class : %s", r.InstanceClass))) + b.WriteString("\n") + multiAZStr := "No" + if r.MultiAZ { + multiAZStr = "Yes" + } + b.WriteString(normalStyle.Render(fmt.Sprintf(" Multi-AZ : %s", multiAZStr))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Storage : %d GB", r.StorageGB))) + b.WriteString("\n") + endpoint := r.Endpoint + if endpoint == "" { + endpoint = dimStyle.Render("(unavailable)") + } + b.WriteString(normalStyle.Render(fmt.Sprintf(" Endpoint : %s", endpoint))) + b.WriteString("\n") + if r.ClusterID != "" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" Cluster : %s", r.ClusterID))) + b.WriteString("\n") + } + + b.WriteString("\n") + suffix := "" + if r.IsClusterMember() { + suffix = " Cluster" + } + b.WriteString(titleStyle.Render("Actions")) + b.WriteString("\n") + if r.CanStart() { + b.WriteString(normalStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) + b.WriteString("\n") + } else { + b.WriteString(dimStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) + b.WriteString("\n") + } + if r.CanStop() { + b.WriteString(normalStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) + b.WriteString("\n") + } else { + b.WriteString(dimStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) + b.WriteString("\n") + } + if r.CanFailover() { + b.WriteString(normalStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) + b.WriteString("\n") + } else { + b.WriteString(dimStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) + b.WriteString("\n") + } + b.WriteString(normalStyle.Render(" [r] Refresh")) + b.WriteString("\n") + + b.WriteString("\n") + b.WriteString(dimStyle.Render("esc: back • H: home")) + return b.String() +} + +func (m Model) viewRDSConfirm() string { + if m.selectedRDS == nil { + return "" + } + r := m.selectedRDS + + // For Aurora cluster members, show cluster-level info + targetLabel := "instance" + targetID := r.DBInstanceID + if r.IsClusterMember() { + targetLabel = "cluster" + targetID = r.ClusterID + } + + var b strings.Builder + b.WriteString(errorStyle.Render("Confirm Action")) + b.WriteString("\n\n") + + if m.rdsAction == "start" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" Are you sure you want to start %s %s?", + targetLabel, targetID))) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(" [y] Yes [n] No")) + b.WriteString("\n") + } else { + b.WriteString(normalStyle.Render(fmt.Sprintf(" You are about to %s %s:", m.rdsAction, targetLabel))) + b.WriteString("\n") + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", targetID))) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Type the %s identifier to confirm:", targetLabel))) + b.WriteString("\n") + b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.rdsConfirmInput))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/app/screen_route53.go b/internal/app/screen_route53.go new file mode 100644 index 0000000..ad5a317 --- /dev/null +++ b/internal/app/screen_route53.go @@ -0,0 +1,385 @@ +package app + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + awsservice "unic/internal/services/aws" +) + +func (m Model) updateRoute53ZoneList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.route53ZoneFilterActive { + switch key { + case "esc": + m.route53ZoneFilterActive = false + case "enter": + m.route53ZoneFilterActive = false + case "backspace": + if len(m.route53ZoneFilter) > 0 { + m.route53ZoneFilter = m.route53ZoneFilter[:len(m.route53ZoneFilter)-1] + m.applyRoute53ZoneFilter() + } + default: + if len(key) == 1 { + m.route53ZoneFilter += key + m.applyRoute53ZoneFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.route53ZoneFilter = "" + m.filteredRoute53Zones = m.route53Zones + m.route53ZoneIdx = 0 + case "up", "k": + if m.route53ZoneIdx > 0 { + m.route53ZoneIdx-- + } + case "down", "j": + if m.route53ZoneIdx < len(m.filteredRoute53Zones)-1 { + m.route53ZoneIdx++ + } + case "/": + m.route53ZoneFilterActive = true + case "enter": + if len(m.filteredRoute53Zones) > 0 && m.route53ZoneIdx < len(m.filteredRoute53Zones) { + selected := m.filteredRoute53Zones[m.route53ZoneIdx] + m.selectedRoute53Zone = &selected + m.screen = screenLoading + return m, m.loadRoute53Records(selected.ID) + } + } + return m, nil +} + +func (m Model) updateRoute53RecordList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.route53RecordFilterActive { + switch key { + case "esc": + m.route53RecordFilterActive = false + case "enter": + m.route53RecordFilterActive = false + case "backspace": + if len(m.route53RecordFilter) > 0 { + m.route53RecordFilter = m.route53RecordFilter[:len(m.route53RecordFilter)-1] + m.applyRoute53RecordFilter() + } + default: + if len(key) == 1 { + m.route53RecordFilter += key + m.applyRoute53RecordFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenRoute53ZoneList + m.route53RecordFilter = "" + m.filteredRoute53Records = m.route53Records + m.route53RecordIdx = 0 + case "up", "k": + if m.route53RecordIdx > 0 { + m.route53RecordIdx-- + } + case "down", "j": + if m.route53RecordIdx < len(m.filteredRoute53Records)-1 { + m.route53RecordIdx++ + } + case "/": + m.route53RecordFilterActive = true + case "enter": + if len(m.filteredRoute53Records) > 0 && m.route53RecordIdx < len(m.filteredRoute53Records) { + selected := m.filteredRoute53Records[m.route53RecordIdx] + m.selectedRoute53Record = &selected + m.screen = screenRoute53RecordDetail + } + } + return m, nil +} + +func (m Model) updateRoute53RecordDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenRoute53RecordList + } + return m, nil +} + +func (m *Model) applyRoute53ZoneFilter() { + if m.route53ZoneFilter == "" { + m.filteredRoute53Zones = m.route53Zones + } else { + query := strings.ToLower(m.route53ZoneFilter) + var result []awsservice.HostedZone + for _, zone := range m.route53Zones { + if strings.Contains(zone.FilterText(), query) { + result = append(result, zone) + } + } + m.filteredRoute53Zones = result + } + m.route53ZoneIdx = 0 +} + +func (m *Model) applyRoute53RecordFilter() { + if m.route53RecordFilter == "" { + m.filteredRoute53Records = m.route53Records + } else { + query := strings.ToLower(m.route53RecordFilter) + var result []awsservice.DNSRecord + for _, rec := range m.route53Records { + if strings.Contains(rec.FilterText(), query) { + result = append(result, rec) + } + } + m.filteredRoute53Records = result + } + m.route53RecordIdx = 0 +} + +func (m Model) loadRoute53Zones() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + zones, err := repo.ListHostedZones(ctx) + if err != nil { + return errMsg{err: err} + } + if len(zones) == 0 { + return errMsg{err: fmt.Errorf("no hosted zones found")} + } + return route53ZonesLoadedMsg{zones: zones} + } +} + +func (m Model) loadRoute53Records(zoneID string) tea.Cmd { + 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} + } + } + + records, err := repo.ListResourceRecordSets(ctx, zoneID) + if err != nil { + return errMsg{err: err} + } + if len(records) == 0 { + return errMsg{err: fmt.Errorf("no records found in zone %s", zoneID)} + } + return route53RecordsLoadedMsg{records: records} + } +} + +func (m Model) viewRoute53ZoneList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Route53 Hosted Zones")) + b.WriteString("\n") + + if m.route53ZoneFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53ZoneFilter))) + } else if m.route53ZoneFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53ZoneFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredRoute53Zones) == 0 { + b.WriteString(dimStyle.Render(" No matching hosted zones")) + b.WriteString("\n") + } else { + // Measure max widths for column alignment + maxName, maxID := 4, 2 // "NAME", "ID" + for _, zone := range m.filteredRoute53Zones { + if len(zone.Name) > maxName { + maxName = len(zone.Name) + } + if len(zone.ID) > maxID { + maxID = len(zone.ID) + } + } + nameCol := lipgloss.NewStyle().Width(maxName + 2) + idCol := lipgloss.NewStyle().Width(maxID + 2) + recordsCol := lipgloss.NewStyle().Width(9) // "RECORDS" + padding + + // Header + b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + idCol.Render("ID") + recordsCol.Render("RECORDS") + "TYPE")) + b.WriteString("\n") + + visibleLines := max(m.height-9, 5) + start := 0 + if m.route53ZoneIdx >= visibleLines { + start = m.route53ZoneIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredRoute53Zones)) + + for i := start; i < end; i++ { + zone := m.filteredRoute53Zones[i] + cursor := " " + style := normalStyle + if i == m.route53ZoneIdx { + cursor = "> " + style = selectedStyle + } + zoneType := "Public" + if zone.IsPrivate { + zoneType = "Private" + } + row := cursor + + nameCol.Inherit(style).Render(zone.Name) + + idCol.Inherit(dimStyle).Render(zone.ID) + + recordsCol.Inherit(dimStyle).Render(fmt.Sprintf("%d", zone.ResourceRecordCount)) + + dimStyle.Render(zoneType) + b.WriteString(row) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d zones", len(m.filteredRoute53Zones), len(m.route53Zones)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: records • esc: back • H: home")) + return b.String() +} + +func (m Model) viewRoute53RecordList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + zoneName := "" + if m.selectedRoute53Zone != nil { + zoneName = m.selectedRoute53Zone.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("DNS Records — %s", zoneName))) + b.WriteString("\n") + + if m.route53RecordFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53RecordFilter))) + } else if m.route53RecordFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53RecordFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredRoute53Records) == 0 { + b.WriteString(dimStyle.Render(" No matching records")) + b.WriteString("\n") + } else { + // Measure max widths for column alignment + maxName, maxType := 4, 4 // "NAME", "TYPE" + for _, rec := range m.filteredRoute53Records { + if len(rec.Name) > maxName { + maxName = len(rec.Name) + } + if len(rec.Type) > maxType { + maxType = len(rec.Type) + } + } + nameCol := lipgloss.NewStyle().Width(maxName + 2) + typeCol := lipgloss.NewStyle().Width(maxType + 2) + + // Header + b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + typeCol.Render("TYPE") + "VALUE")) + b.WriteString("\n") + + visibleLines := max(m.height-9, 5) + start := 0 + if m.route53RecordIdx >= visibleLines { + start = m.route53RecordIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredRoute53Records)) + + for i := start; i < end; i++ { + rec := m.filteredRoute53Records[i] + cursor := " " + style := normalStyle + if i == m.route53RecordIdx { + cursor = "> " + style = selectedStyle + } + // Build value string + valStr := "" + if rec.AliasTarget != "" { + valStr = "ALIAS → " + rec.AliasTarget + } else { + valStr = strings.Join(rec.Values, ", ") + } + if len(valStr) > 60 { + valStr = valStr[:57] + "..." + } + row := cursor + + nameCol.Inherit(style).Render(rec.Name) + + typeCol.Inherit(filterStyle).Render(rec.Type) + + dimStyle.Render(valStr) + b.WriteString(row) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d records", len(m.filteredRoute53Records), len(m.route53Records)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewRoute53RecordDetail() string { + if m.selectedRoute53Record == nil { + return "" + } + r := m.selectedRoute53Record + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("DNS Record Detail")) + b.WriteString("\n\n") + + labelStyle := dimStyle.Width(10) + + b.WriteString(" " + labelStyle.Render("Name") + normalStyle.Render(r.Name)) + b.WriteString("\n") + b.WriteString(" " + labelStyle.Render("Type") + filterStyle.Render(r.Type)) + b.WriteString("\n") + + if r.AliasTarget != "" { + b.WriteString(" " + labelStyle.Render("Alias") + normalStyle.Render(r.AliasTarget)) + b.WriteString("\n") + } else { + b.WriteString(" " + labelStyle.Render("TTL") + normalStyle.Render(fmt.Sprintf("%d", r.TTL))) + b.WriteString("\n") + } + + if len(r.Values) > 0 { + b.WriteString(" " + labelStyle.Render("Values")) + b.WriteString("\n") + for _, v := range r.Values { + b.WriteString(" " + labelStyle.Render("") + normalStyle.Render(v)) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("esc: back • H: home")) + return b.String() +} diff --git a/internal/app/screen_vpc.go b/internal/app/screen_vpc.go new file mode 100644 index 0000000..9332570 --- /dev/null +++ b/internal/app/screen_vpc.go @@ -0,0 +1,302 @@ +package app + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +func (m Model) updateVPCList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenFeatureList + m.vpcIdx = 0 + case "up", "k": + if m.vpcIdx > 0 { + m.vpcIdx-- + } + case "down", "j": + if m.vpcIdx < len(m.filteredVPCs)-1 { + m.vpcIdx++ + } + case "enter": + if len(m.filteredVPCs) > 0 && m.vpcIdx < len(m.filteredVPCs) { + selected := m.filteredVPCs[m.vpcIdx] + m.selectedVPC = &selected + m.screen = screenLoading + return m, m.loadSubnets(selected.VPCID) + } + } + return m, nil +} + +func (m Model) updateSubnetList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenVPCList + m.subnetIdx = 0 + case "up", "k": + if m.subnetIdx > 0 { + m.subnetIdx-- + } + case "down", "j": + if m.subnetIdx < len(m.subnets)-1 { + m.subnetIdx++ + } + case "enter": + if len(m.subnets) > 0 && m.subnetIdx < len(m.subnets) { + selected := m.subnets[m.subnetIdx] + m.selectedSubnet = &selected + m.screen = screenLoading + return m, m.loadAvailableIPs(selected) + } + } + return m, nil +} + +func (m Model) updateSubnetDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.ipFilterActive { + switch key { + case "esc", "enter": + m.ipFilterActive = false + case "backspace": + if len(m.ipFilter) > 0 { + m.ipFilter = m.ipFilter[:len(m.ipFilter)-1] + m.applyIPFilter() + } + default: + if len(key) == 1 { + m.ipFilter += key + m.applyIPFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenSubnetList + case "up", "k": + if m.ipScrollOffset > 0 { + m.ipScrollOffset-- + } + case "down", "j": + visibleLines := max(m.height-12, 5) + if m.ipScrollOffset < len(m.filteredIPs)-visibleLines { + m.ipScrollOffset++ + } + case "/": + m.ipFilterActive = true + } + return m, nil +} + +func (m *Model) applyIPFilter() { + if m.ipFilter == "" { + m.filteredIPs = m.availableIPs + } else { + var result []string + for _, ip := range m.availableIPs { + if strings.Contains(ip, m.ipFilter) { + result = append(result, ip) + } + } + m.filteredIPs = result + } + m.ipScrollOffset = 0 +} + +func (m Model) loadVPCs() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + vpcs, err := repo.ListVPCs(ctx) + if err != nil { + return errMsg{err: err} + } + if len(vpcs) == 0 { + return errMsg{err: fmt.Errorf("no VPCs found")} + } + return vpcsLoadedMsg{vpcs: vpcs} + } +} + +func (m Model) loadSubnets(vpcID string) tea.Cmd { + 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} + } + } + + subnets, err := repo.ListSubnets(ctx, vpcID) + if err != nil { + return errMsg{err: err} + } + if len(subnets) == 0 { + return errMsg{err: fmt.Errorf("no subnets found in VPC %s", vpcID)} + } + return subnetsLoadedMsg{subnets: subnets} + } +} + +func (m Model) loadAvailableIPs(subnet awsservice.Subnet) tea.Cmd { + 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} + } + } + ips, err := repo.ListAvailableIPs(ctx, subnet.SubnetID, subnet.CIDR) + if err != nil { + return errMsg{err: err} + } + return availableIPsLoadedMsg{subnet: subnet, ips: ips} + } +} + +func (m Model) viewVPCList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("VPCs")) + b.WriteString("\n\n") + + if len(m.filteredVPCs) == 0 { + b.WriteString(dimStyle.Render(" No VPCs found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.vpcIdx >= visibleLines { + start = m.vpcIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredVPCs)) + + for i := start; i < end; i++ { + vpc := m.filteredVPCs[i] + cursor := " " + style := normalStyle + if i == m.vpcIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, vpc.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d VPCs", len(m.filteredVPCs)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back • H: home")) + return b.String() +} + +func (m Model) viewSubnetList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + vpcName := "" + if m.selectedVPC != nil { + vpcName = fmt.Sprintf(" (%s)", m.selectedVPC.Name) + } + b.WriteString(titleStyle.Render(fmt.Sprintf("Subnets%s", vpcName))) + b.WriteString("\n\n") + + if len(m.subnets) == 0 { + b.WriteString(dimStyle.Render(" No subnets found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.subnetIdx >= visibleLines { + start = m.subnetIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.subnets)) + + for i := start; i < end; i++ { + s := m.subnets[i] + cursor := " " + style := normalStyle + if i == m.subnetIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d subnets", len(m.subnets)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewSubnetDetail() string { + if m.selectedSubnet == nil { + return "" + } + s := m.selectedSubnet + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Subnet Detail")) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Subnet ID : %s", s.SubnetID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Name : %s", s.Name))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" CIDR : %s", s.CIDR))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" AZ : %s", s.AvailabilityZone))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Available IPs : %d", len(m.availableIPs)))) + b.WriteString("\n\n") + + // Filter bar + if m.ipFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.ipFilter))) + } else if m.ipFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.ipFilter))) + } + b.WriteString("\n") + + if len(m.filteredIPs) == 0 { + b.WriteString(dimStyle.Render(" No matching IPs")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-14, 5) + start := m.ipScrollOffset + end := min(start+visibleLines, len(m.filteredIPs)) + + for _, ip := range m.filteredIPs[start:end] { + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", ip))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d-%d of %d IPs", start+1, end, len(m.filteredIPs)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: scroll • /: filter • esc: back • H: home")) + return b.String() +} diff --git a/internal/app/styles.go b/internal/app/styles.go new file mode 100644 index 0000000..2ad3c94 --- /dev/null +++ b/internal/app/styles.go @@ -0,0 +1,90 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) + normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + filterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("236")) +) + +func (m Model) renderStatusBar() string { + var parts []string + + if m.cfg.ContextName != "" { + parts = append(parts, fmt.Sprintf("[%s]", m.cfg.ContextName)) + } + parts = append(parts, fmt.Sprintf("region:%s", m.cfg.Region)) + if m.cfg.AuthType != "" { + parts = append(parts, fmt.Sprintf("auth:%s", m.cfg.AuthType)) + } + if m.callerIdentity != nil && m.callerIdentity.Account != "" { + parts = append(parts, fmt.Sprintf("account:%s", m.callerIdentity.Account)) + } + + bar := strings.Join(parts, " ") + if m.width > 0 { + if len(bar) < m.width { + bar += strings.Repeat(" ", m.width-len(bar)) + } + } + return statusBarStyle.Render(bar) + "\n\n" +} + +// fitToHeight ensures the rendered output is exactly m.height lines. +// It pads short content with blank lines and truncates long content, +// keeping both the header (top) and footer (bottom) visible by trimming +// from the middle of the content area. +func (m Model) fitToHeight(s string) string { + if m.height <= 0 { + return s + } + lines := strings.Split(s, "\n") + // Remove trailing empty line if present (common from trailing \n) + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) <= m.height { + // Pad to exact height so the terminal doesn't shift + for len(lines) < m.height { + lines = append(lines, "") + } + return strings.Join(lines, "\n") + } + // Content overflows: keep first (height-2) lines + last 1 line (footer) + // with a "..." indicator + footerLines := 1 + headerLines := m.height - footerLines - 1 // -1 for the "..." line + if headerLines < 1 { + headerLines = 1 + } + result := make([]string, 0, m.height) + result = append(result, lines[:headerLines]...) + result = append(result, dimStyle.Render(" ...")) + result = append(result, lines[len(lines)-footerLines:]...) + return strings.Join(result, "\n") +} + +func (m Model) viewLoading() string { + return titleStyle.Render("Loading...") +} + +func (m Model) viewError() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(errorStyle.Render("Error")) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(m.errMsg)) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render("enter/esc: go back • q: quit")) + return b.String() +}